cli.py 213 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509
  1. #
  2. # dulwich - Simple command-line interface to Dulwich
  3. # Copyright (C) 2008-2011 Jelmer Vernooij <jelmer@jelmer.uk>
  4. # vim: expandtab
  5. #
  6. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  7. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  8. # General Public License as published by the Free Software Foundation; version 2.0
  9. # or (at your option) any later version. You can redistribute it and/or
  10. # modify it under the terms of either of these two licenses.
  11. #
  12. # Unless required by applicable law or agreed to in writing, software
  13. # distributed under the License is distributed on an "AS IS" BASIS,
  14. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  15. # See the License for the specific language governing permissions and
  16. # limitations under the License.
  17. #
  18. # You should have received a copy of the licenses; if not, see
  19. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  20. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  21. # License, Version 2.0.
  22. #
  23. """Simple command-line interface to Dulwich>.
  24. This is a very simple command-line wrapper for Dulwich. It is by
  25. no means intended to be a full-blown Git command-line interface but just
  26. a way to test Dulwich.
  27. """
  28. # TODO: Add support for GIT_NAMESPACE environment variable by wrapping
  29. # repository refs with NamespacedRefsContainer when the environment
  30. # variable is set. See issue #1809 and dulwich.refs.NamespacedRefsContainer.
  31. import argparse
  32. import io
  33. import logging
  34. import os
  35. import shutil
  36. import signal
  37. import subprocess
  38. import sys
  39. import tempfile
  40. import types
  41. from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
  42. from pathlib import Path
  43. from types import TracebackType
  44. from typing import (
  45. BinaryIO,
  46. ClassVar,
  47. TextIO,
  48. )
  49. if sys.version_info >= (3, 12):
  50. from collections.abc import Buffer
  51. else:
  52. Buffer = bytes | bytearray | memoryview
  53. from dulwich import porcelain
  54. from .bundle import Bundle, create_bundle_from_repo, read_bundle, write_bundle
  55. from .client import get_transport_and_path
  56. from .config import Config
  57. from .errors import (
  58. ApplyDeltaError,
  59. FileFormatException,
  60. GitProtocolError,
  61. NotGitRepository,
  62. )
  63. from .index import Index
  64. from .log_utils import _configure_logging_from_trace
  65. from .objects import Commit, sha_to_hex, valid_hexsha
  66. from .objectspec import parse_commit_range
  67. from .pack import Pack
  68. from .patch import DiffAlgorithmNotAvailable
  69. from .repo import Repo
  70. logger = logging.getLogger(__name__)
  71. def to_display_str(value: bytes | str) -> str:
  72. """Convert a bytes or string value to a display string.
  73. Args:
  74. value: The value to convert (bytes or str)
  75. Returns:
  76. A string suitable for display
  77. """
  78. if isinstance(value, bytes):
  79. return value.decode("utf-8", "replace")
  80. return value
  81. def _should_auto_flush(
  82. stream: TextIO | BinaryIO, env: Mapping[str, str] | None = None
  83. ) -> bool:
  84. """Determine if output should be auto-flushed based on GIT_FLUSH environment variable.
  85. Args:
  86. stream: The output stream to check
  87. env: Environment variables dict (defaults to os.environ)
  88. Returns:
  89. True if output should be flushed after each write, False otherwise
  90. """
  91. if env is None:
  92. env = os.environ
  93. git_flush = env.get("GIT_FLUSH", "").strip()
  94. if git_flush == "1":
  95. return True
  96. elif git_flush == "0":
  97. return False
  98. else:
  99. # Auto-detect: don't flush if redirected to a file
  100. return hasattr(stream, "isatty") and not stream.isatty()
  101. class AutoFlushTextIOWrapper:
  102. """Wrapper that automatically flushes a TextIO stream based on configuration.
  103. This wrapper can be configured to flush after each write operation,
  104. which is useful for real-time output monitoring in CI/CD systems.
  105. """
  106. def __init__(self, stream: TextIO) -> None:
  107. """Initialize the wrapper.
  108. Args:
  109. stream: The stream to wrap
  110. """
  111. self._stream = stream
  112. @classmethod
  113. def env(
  114. cls, stream: TextIO, env: Mapping[str, str] | None = None
  115. ) -> "AutoFlushTextIOWrapper | TextIO":
  116. """Create wrapper respecting the GIT_FLUSH environment variable.
  117. Respects the GIT_FLUSH environment variable:
  118. - GIT_FLUSH=1: Always flush after each write
  119. - GIT_FLUSH=0: Never auto-flush (use buffered I/O)
  120. - Not set: Auto-detect based on whether output is redirected
  121. Args:
  122. stream: The stream to wrap
  123. env: Environment variables dict (defaults to os.environ)
  124. Returns:
  125. AutoFlushTextIOWrapper instance configured based on GIT_FLUSH
  126. """
  127. if _should_auto_flush(stream, env):
  128. return cls(stream)
  129. else:
  130. return stream
  131. def write(self, data: str) -> int:
  132. """Write data to the stream and optionally flush.
  133. Args:
  134. data: Data to write
  135. Returns:
  136. Number of characters written
  137. """
  138. result = self._stream.write(data)
  139. self._stream.flush()
  140. return result
  141. def writelines(self, lines: Iterable[str]) -> None:
  142. """Write multiple lines to the stream and optionally flush.
  143. Args:
  144. lines: Lines to write
  145. """
  146. self._stream.writelines(lines)
  147. self._stream.flush()
  148. def flush(self) -> None:
  149. """Flush the underlying stream."""
  150. self._stream.flush()
  151. def __getattr__(self, name: str) -> object:
  152. """Delegate all other attributes to the underlying stream."""
  153. return getattr(self._stream, name)
  154. def __enter__(self) -> "AutoFlushTextIOWrapper":
  155. """Support context manager protocol."""
  156. return self
  157. def __exit__(
  158. self,
  159. exc_type: type[BaseException] | None,
  160. exc_val: BaseException | None,
  161. exc_tb: TracebackType | None,
  162. ) -> None:
  163. """Support context manager protocol."""
  164. if hasattr(self._stream, "__exit__"):
  165. self._stream.__exit__(exc_type, exc_val, exc_tb)
  166. class AutoFlushBinaryIOWrapper:
  167. """Wrapper that automatically flushes a BinaryIO stream based on configuration.
  168. This wrapper can be configured to flush after each write operation,
  169. which is useful for real-time output monitoring in CI/CD systems.
  170. """
  171. def __init__(self, stream: BinaryIO) -> None:
  172. """Initialize the wrapper.
  173. Args:
  174. stream: The stream to wrap
  175. """
  176. self._stream = stream
  177. @classmethod
  178. def env(
  179. cls, stream: BinaryIO, env: Mapping[str, str] | None = None
  180. ) -> "AutoFlushBinaryIOWrapper | BinaryIO":
  181. """Create wrapper respecting the GIT_FLUSH environment variable.
  182. Respects the GIT_FLUSH environment variable:
  183. - GIT_FLUSH=1: Always flush after each write
  184. - GIT_FLUSH=0: Never auto-flush (use buffered I/O)
  185. - Not set: Auto-detect based on whether output is redirected
  186. Args:
  187. stream: The stream to wrap
  188. env: Environment variables dict (defaults to os.environ)
  189. Returns:
  190. AutoFlushBinaryIOWrapper instance configured based on GIT_FLUSH
  191. """
  192. if _should_auto_flush(stream, env):
  193. return cls(stream)
  194. else:
  195. return stream
  196. def write(self, data: Buffer) -> int:
  197. """Write data to the stream and optionally flush.
  198. Args:
  199. data: Data to write
  200. Returns:
  201. Number of bytes written
  202. """
  203. result = self._stream.write(data)
  204. self._stream.flush()
  205. return result
  206. def writelines(self, lines: Iterable[Buffer]) -> None:
  207. """Write multiple lines to the stream and optionally flush.
  208. Args:
  209. lines: Lines to write
  210. """
  211. self._stream.writelines(lines)
  212. self._stream.flush()
  213. def flush(self) -> None:
  214. """Flush the underlying stream."""
  215. self._stream.flush()
  216. def __getattr__(self, name: str) -> object:
  217. """Delegate all other attributes to the underlying stream."""
  218. return getattr(self._stream, name)
  219. def __enter__(self) -> "AutoFlushBinaryIOWrapper":
  220. """Support context manager protocol."""
  221. return self
  222. def __exit__(
  223. self,
  224. exc_type: type[BaseException] | None,
  225. exc_val: BaseException | None,
  226. exc_tb: TracebackType | None,
  227. ) -> None:
  228. """Support context manager protocol."""
  229. if hasattr(self._stream, "__exit__"):
  230. self._stream.__exit__(exc_type, exc_val, exc_tb)
  231. class CommitMessageError(Exception):
  232. """Raised when there's an issue with the commit message."""
  233. def signal_int(signal: int, frame: types.FrameType | None) -> None:
  234. """Handle interrupt signal by exiting.
  235. Args:
  236. signal: Signal number
  237. frame: Current stack frame
  238. """
  239. sys.exit(1)
  240. def signal_quit(signal: int, frame: types.FrameType | None) -> None:
  241. """Handle quit signal by entering debugger.
  242. Args:
  243. signal: Signal number
  244. frame: Current stack frame
  245. """
  246. import pdb
  247. pdb.set_trace()
  248. def parse_time_to_timestamp(time_spec: str) -> int:
  249. """Parse a time specification and return a Unix timestamp.
  250. Args:
  251. time_spec: Time specification. Can be:
  252. - A Unix timestamp (integer as string)
  253. - A relative time like "2 weeks ago"
  254. - "now" for current time
  255. - "all" to expire all entries (returns future time)
  256. - "never" to never expire (returns 0 - epoch start)
  257. Returns:
  258. Unix timestamp
  259. Raises:
  260. ValueError: If the time specification cannot be parsed
  261. """
  262. import time
  263. from .approxidate import parse_approxidate
  264. # Handle special cases specific to CLI
  265. if time_spec == "all":
  266. # Expire all entries - set to future time so everything is "older"
  267. return int(time.time()) + (100 * 365 * 24 * 60 * 60) # 100 years in future
  268. if time_spec == "never":
  269. # Never expire - set to epoch start so nothing is older
  270. return 0
  271. # Use approxidate parser for everything else
  272. return parse_approxidate(time_spec)
  273. def format_bytes(bytes: float) -> str:
  274. """Format bytes as human-readable string.
  275. Args:
  276. bytes: Number of bytes
  277. Returns:
  278. Human-readable string like "1.5 MB"
  279. """
  280. for unit in ["B", "KB", "MB", "GB"]:
  281. if bytes < 1024.0:
  282. return f"{bytes:.1f} {unit}"
  283. bytes /= 1024.0
  284. return f"{bytes:.1f} TB"
  285. def launch_editor(template_content: bytes = b"") -> bytes:
  286. """Launch an editor for the user to enter text.
  287. Args:
  288. template_content: Initial content for the editor
  289. Returns:
  290. The edited content as bytes
  291. """
  292. # Determine which editor to use
  293. editor = os.environ.get("GIT_EDITOR") or os.environ.get("EDITOR") or "vi"
  294. # Create a temporary file
  295. with tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=".txt") as f:
  296. temp_file = f.name
  297. f.write(template_content)
  298. try:
  299. # Launch the editor
  300. subprocess.run([editor, temp_file], check=True)
  301. # Read the edited content
  302. with open(temp_file, "rb") as f:
  303. content = f.read()
  304. return content
  305. finally:
  306. # Clean up the temporary file
  307. os.unlink(temp_file)
  308. def detect_terminal_width() -> int:
  309. """Detect the width of the terminal.
  310. Returns:
  311. Width of the terminal in characters, or 80 if it cannot be determined
  312. """
  313. try:
  314. return os.get_terminal_size().columns
  315. except OSError:
  316. return 80
  317. def write_columns(
  318. items: Iterator[bytes] | Sequence[bytes],
  319. out: TextIO,
  320. width: int | None = None,
  321. ) -> None:
  322. """Display items in formatted columns based on terminal width.
  323. Args:
  324. items: List or iterator of bytes objects to display in columns
  325. out: Output stream to write to
  326. width: Optional width of the terminal (if None, auto-detect)
  327. The function calculates the optimal number of columns to fit the terminal
  328. width and displays the items in a formatted column layout with proper
  329. padding and alignment.
  330. """
  331. if width is None:
  332. ter_width = detect_terminal_width()
  333. else:
  334. ter_width = width
  335. item_names = [item.decode() for item in items]
  336. def columns(
  337. names: Sequence[str], width: int, num_cols: int
  338. ) -> tuple[bool, list[int]]:
  339. if num_cols <= 0:
  340. return False, []
  341. num_rows = (len(names) + num_cols - 1) // num_cols
  342. col_widths = []
  343. for col in range(num_cols):
  344. max_width = 0
  345. for row in range(num_rows):
  346. idx = row + col * num_rows
  347. if idx < len(names):
  348. max_width = max(max_width, len(names[idx]))
  349. col_widths.append(max_width + 2) # add padding
  350. total_width = sum(col_widths)
  351. if total_width <= width:
  352. return True, col_widths
  353. return False, []
  354. best_cols = 1
  355. best_widths = []
  356. for num_cols in range(min(8, len(item_names)), 0, -1):
  357. fits, widths = columns(item_names, ter_width, num_cols)
  358. if fits:
  359. best_cols = num_cols
  360. best_widths = widths
  361. break
  362. if not best_widths:
  363. best_cols = 1
  364. best_widths = [max(len(name) for name in item_names) + 2]
  365. num_rows = (len(item_names) + best_cols - 1) // best_cols
  366. for row in range(num_rows):
  367. lines = []
  368. for col in range(best_cols):
  369. idx = row + col * num_rows
  370. if idx < len(item_names):
  371. branch_name = item_names[idx]
  372. if col < len(best_widths):
  373. lines.append(branch_name.ljust(best_widths[col]))
  374. else:
  375. lines.append(branch_name)
  376. if lines:
  377. out.write("".join(lines).rstrip() + "\n")
  378. def format_columns(
  379. items: list[str],
  380. width: int | None = None,
  381. mode: str = "column",
  382. padding: int = 1,
  383. indent: str = "",
  384. nl: str = "\n",
  385. ) -> str:
  386. r"""Format items into columns with various layout modes.
  387. Args:
  388. items: List of strings to format
  389. width: Terminal width (auto-detected if None)
  390. mode: Layout mode - "column" (fill columns first), "row" (fill rows first),
  391. "plain" (one column), or add ",dense" for unequal column widths
  392. padding: Number of spaces between columns
  393. indent: String to prepend to each line
  394. nl: String to append to each line (including newline)
  395. Returns:
  396. Formatted string with items in columns
  397. Examples:
  398. >>> format_columns(["a", "b", "c"], width=20, mode="column")
  399. "a b\\nc\\n"
  400. >>> format_columns(["a", "b", "c"], width=20, mode="row")
  401. "a b c\\n"
  402. """
  403. if not items:
  404. return ""
  405. if width is None:
  406. width = detect_terminal_width()
  407. # Parse mode
  408. mode_parts = mode.split(",")
  409. layout_mode = "column"
  410. dense = False
  411. for part in mode_parts:
  412. part = part.strip()
  413. if part in ("column", "row", "plain"):
  414. layout_mode = part
  415. elif part == "dense":
  416. dense = True
  417. elif part == "nodense":
  418. dense = False
  419. # Plain mode - one item per line
  420. if layout_mode == "plain":
  421. return "".join(indent + item + nl for item in items)
  422. # Calculate available width for content (excluding indent)
  423. available_width = width - len(indent)
  424. if available_width <= 0:
  425. available_width = width
  426. # Find optimal number of columns
  427. max_item_len = max(len(item) for item in items)
  428. # Start with maximum possible columns and work down
  429. best_num_cols = 1
  430. best_col_widths: list[int] = []
  431. for num_cols in range(min(len(items), 20), 0, -1):
  432. if layout_mode == "column":
  433. # Column mode: fill columns first (items go down, then across)
  434. num_rows = (len(items) + num_cols - 1) // num_cols
  435. else: # row mode
  436. # Row mode: fill rows first (items go across, then down)
  437. num_rows = (len(items) + num_cols - 1) // num_cols
  438. col_widths: list[int] = []
  439. if dense:
  440. # Calculate width for each column based on its contents
  441. for col in range(num_cols):
  442. max_width = 0
  443. for row in range(num_rows):
  444. if layout_mode == "column":
  445. idx = row + col * num_rows
  446. else: # row mode
  447. idx = row * num_cols + col
  448. if idx < len(items):
  449. max_width = max(max_width, len(items[idx]))
  450. if max_width > 0:
  451. col_widths.append(max_width)
  452. else:
  453. # All columns same width (nodense)
  454. max_width = 0
  455. for col in range(num_cols):
  456. for row in range(num_rows):
  457. if layout_mode == "column":
  458. idx = row + col * num_rows
  459. else: # row mode
  460. idx = row * num_cols + col
  461. if idx < len(items):
  462. max_width = max(max_width, len(items[idx]))
  463. col_widths = [max_width] * num_cols
  464. # Calculate total width including padding (but not after last column)
  465. total_width = sum(col_widths) + padding * (len(col_widths) - 1)
  466. if total_width <= available_width:
  467. best_num_cols = num_cols
  468. best_col_widths = col_widths
  469. break
  470. # If no fit found, use single column
  471. if not best_col_widths:
  472. best_num_cols = 1
  473. best_col_widths = [max_item_len]
  474. # Format output
  475. num_rows = (len(items) + best_num_cols - 1) // best_num_cols
  476. lines = []
  477. for row in range(num_rows):
  478. line_parts = []
  479. for col in range(best_num_cols):
  480. if layout_mode == "column":
  481. idx = row + col * num_rows
  482. else: # row mode
  483. idx = row * best_num_cols + col
  484. if idx < len(items):
  485. item = items[idx]
  486. # Pad item to column width, except for last column in row
  487. if col < best_num_cols - 1 and col < len(best_col_widths) - 1:
  488. item = item.ljust(best_col_widths[col] + padding)
  489. line_parts.append(item)
  490. if line_parts:
  491. lines.append(indent + "".join(line_parts).rstrip() + nl)
  492. return "".join(lines)
  493. class PagerBuffer(BinaryIO):
  494. """Binary buffer wrapper for Pager to mimic sys.stdout.buffer."""
  495. def __init__(self, pager: "Pager") -> None:
  496. """Initialize PagerBuffer.
  497. Args:
  498. pager: Pager instance to wrap
  499. """
  500. self.pager = pager
  501. def write(self, data: bytes | bytearray | memoryview) -> int: # type: ignore[override]
  502. """Write bytes to pager."""
  503. # Convert to bytes and decode to string for the pager
  504. text = bytes(data).decode("utf-8", errors="replace")
  505. return self.pager.write(text)
  506. def flush(self) -> None:
  507. """Flush the pager."""
  508. return self.pager.flush()
  509. def writelines(self, lines: Iterable[bytes | bytearray | memoryview]) -> None: # type: ignore[override]
  510. """Write multiple lines to pager."""
  511. for line in lines:
  512. self.write(line)
  513. def readable(self) -> bool:
  514. """Return whether the buffer is readable (it's not)."""
  515. return False
  516. def writable(self) -> bool:
  517. """Return whether the buffer is writable."""
  518. return not self.pager._closed
  519. def seekable(self) -> bool:
  520. """Return whether the buffer is seekable (it's not)."""
  521. return False
  522. def close(self) -> None:
  523. """Close the pager."""
  524. return self.pager.close()
  525. @property
  526. def closed(self) -> bool:
  527. """Return whether the buffer is closed."""
  528. return self.pager.closed
  529. @property
  530. def mode(self) -> str:
  531. """Return the mode."""
  532. return "wb"
  533. @property
  534. def name(self) -> str:
  535. """Return the name."""
  536. return "<pager.buffer>"
  537. def fileno(self) -> int:
  538. """Return the file descriptor (not supported)."""
  539. raise io.UnsupportedOperation("PagerBuffer does not support fileno()")
  540. def isatty(self) -> bool:
  541. """Return whether the buffer is a TTY."""
  542. return False
  543. def read(self, size: int = -1) -> bytes:
  544. """Read from the buffer (not supported)."""
  545. raise io.UnsupportedOperation("PagerBuffer does not support reading")
  546. def read1(self, size: int = -1) -> bytes:
  547. """Read from the buffer (not supported)."""
  548. raise io.UnsupportedOperation("PagerBuffer does not support reading")
  549. def readinto(self, b: bytearray) -> int:
  550. """Read into buffer (not supported)."""
  551. raise io.UnsupportedOperation("PagerBuffer does not support reading")
  552. def readinto1(self, b: bytearray) -> int:
  553. """Read into buffer (not supported)."""
  554. raise io.UnsupportedOperation("PagerBuffer does not support reading")
  555. def readline(self, size: int = -1) -> bytes:
  556. """Read a line from the buffer (not supported)."""
  557. raise io.UnsupportedOperation("PagerBuffer does not support reading")
  558. def readlines(self, hint: int = -1) -> list[bytes]:
  559. """Read lines from the buffer (not supported)."""
  560. raise io.UnsupportedOperation("PagerBuffer does not support reading")
  561. def seek(self, offset: int, whence: int = 0) -> int:
  562. """Seek in the buffer (not supported)."""
  563. raise io.UnsupportedOperation("PagerBuffer does not support seeking")
  564. def tell(self) -> int:
  565. """Return the current position (not supported)."""
  566. raise io.UnsupportedOperation("PagerBuffer does not support tell()")
  567. def truncate(self, size: int | None = None) -> int:
  568. """Truncate the buffer (not supported)."""
  569. raise io.UnsupportedOperation("PagerBuffer does not support truncation")
  570. def __iter__(self) -> "PagerBuffer":
  571. """Return iterator (not supported)."""
  572. raise io.UnsupportedOperation("PagerBuffer does not support iteration")
  573. def __next__(self) -> bytes:
  574. """Return next line (not supported)."""
  575. raise io.UnsupportedOperation("PagerBuffer does not support iteration")
  576. def __enter__(self) -> "PagerBuffer":
  577. """Enter context manager."""
  578. return self
  579. def __exit__(
  580. self,
  581. exc_type: type[BaseException] | None,
  582. exc_val: BaseException | None,
  583. exc_tb: TracebackType | None,
  584. ) -> None:
  585. """Exit context manager."""
  586. self.close()
  587. class Pager(TextIO):
  588. """File-like object that pages output through external pager programs."""
  589. def __init__(self, pager_cmd: str = "cat") -> None:
  590. """Initialize Pager.
  591. Args:
  592. pager_cmd: Command to use for paging (default: "cat")
  593. """
  594. self.pager_process: subprocess.Popen[str] | None = None
  595. self._buffer = PagerBuffer(self)
  596. self._closed = False
  597. self.pager_cmd = pager_cmd
  598. self._pager_died = False
  599. def _get_pager_command(self) -> str:
  600. """Get the pager command to use."""
  601. return self.pager_cmd
  602. def _ensure_pager_started(self) -> None:
  603. """Start the pager process if not already started."""
  604. if self.pager_process is None and not self._closed:
  605. try:
  606. pager_cmd = self._get_pager_command()
  607. self.pager_process = subprocess.Popen(
  608. pager_cmd,
  609. shell=True,
  610. stdin=subprocess.PIPE,
  611. stdout=sys.stdout,
  612. stderr=sys.stderr,
  613. text=True,
  614. )
  615. except (OSError, subprocess.SubprocessError):
  616. # Pager failed to start, fall back to direct output
  617. self.pager_process = None
  618. def write(self, text: str) -> int:
  619. """Write text to the pager."""
  620. if self._closed:
  621. raise ValueError("I/O operation on closed file")
  622. # If pager died (user quit), stop writing output
  623. if self._pager_died:
  624. return len(text)
  625. self._ensure_pager_started()
  626. if self.pager_process and self.pager_process.stdin:
  627. try:
  628. result = self.pager_process.stdin.write(text)
  629. assert isinstance(result, int)
  630. return result
  631. except (OSError, subprocess.SubprocessError, BrokenPipeError):
  632. # Pager died (user quit), stop writing output
  633. self._pager_died = True
  634. return len(text)
  635. else:
  636. # No pager available, write directly to stdout
  637. return sys.stdout.write(text)
  638. def flush(self) -> None:
  639. """Flush the pager."""
  640. if self._closed or self._pager_died:
  641. return
  642. if self.pager_process and self.pager_process.stdin:
  643. try:
  644. self.pager_process.stdin.flush()
  645. except (OSError, subprocess.SubprocessError, BrokenPipeError):
  646. self._pager_died = True
  647. else:
  648. sys.stdout.flush()
  649. def close(self) -> None:
  650. """Close the pager."""
  651. if self._closed:
  652. return
  653. self._closed = True
  654. if self.pager_process:
  655. try:
  656. if self.pager_process.stdin:
  657. self.pager_process.stdin.close()
  658. self.pager_process.wait()
  659. except (OSError, subprocess.SubprocessError):
  660. pass
  661. self.pager_process = None
  662. def __enter__(self) -> "Pager":
  663. """Context manager entry."""
  664. return self
  665. def __exit__(
  666. self,
  667. exc_type: type | None,
  668. exc_val: BaseException | None,
  669. exc_tb: types.TracebackType | None,
  670. ) -> None:
  671. """Context manager exit."""
  672. self.close()
  673. # Additional file-like methods for compatibility
  674. def writelines(self, lines: Iterable[str]) -> None:
  675. """Write a list of lines to the pager."""
  676. if self._pager_died:
  677. return
  678. for line in lines:
  679. self.write(line)
  680. @property
  681. def closed(self) -> bool:
  682. """Return whether the pager is closed."""
  683. return self._closed
  684. def readable(self) -> bool:
  685. """Return whether the pager is readable (it's not)."""
  686. return False
  687. def writable(self) -> bool:
  688. """Return whether the pager is writable."""
  689. return not self._closed
  690. def seekable(self) -> bool:
  691. """Return whether the pager is seekable (it's not)."""
  692. return False
  693. @property
  694. def buffer(self) -> BinaryIO:
  695. """Return the underlying binary buffer."""
  696. return self._buffer
  697. @property
  698. def encoding(self) -> str:
  699. """Return the encoding used."""
  700. return "utf-8"
  701. @property
  702. def errors(self) -> str | None:
  703. """Return the error handling scheme."""
  704. return "replace"
  705. def fileno(self) -> int:
  706. """Return the file descriptor (not supported)."""
  707. raise io.UnsupportedOperation("Pager does not support fileno()")
  708. def isatty(self) -> bool:
  709. """Return whether the pager is a TTY."""
  710. return False
  711. @property
  712. def line_buffering(self) -> bool:
  713. """Return whether line buffering is enabled."""
  714. return True
  715. @property
  716. def mode(self) -> str:
  717. """Return the mode."""
  718. return "w"
  719. @property
  720. def name(self) -> str:
  721. """Return the name."""
  722. return "<pager>"
  723. @property
  724. def newlines(self) -> str | tuple[str, ...] | None:
  725. """Return the newlines mode."""
  726. return None
  727. def read(self, size: int = -1) -> str:
  728. """Read from the pager (not supported)."""
  729. raise io.UnsupportedOperation("Pager does not support reading")
  730. def readline(self, size: int = -1) -> str:
  731. """Read a line from the pager (not supported)."""
  732. raise io.UnsupportedOperation("Pager does not support reading")
  733. def readlines(self, hint: int = -1) -> list[str]:
  734. """Read lines from the pager (not supported)."""
  735. raise io.UnsupportedOperation("Pager does not support reading")
  736. def seek(self, offset: int, whence: int = 0) -> int:
  737. """Seek in the pager (not supported)."""
  738. raise io.UnsupportedOperation("Pager does not support seeking")
  739. def tell(self) -> int:
  740. """Return the current position (not supported)."""
  741. raise io.UnsupportedOperation("Pager does not support tell()")
  742. def truncate(self, size: int | None = None) -> int:
  743. """Truncate the pager (not supported)."""
  744. raise io.UnsupportedOperation("Pager does not support truncation")
  745. def __iter__(self) -> "Pager":
  746. """Return iterator (not supported)."""
  747. raise io.UnsupportedOperation("Pager does not support iteration")
  748. def __next__(self) -> str:
  749. """Return next line (not supported)."""
  750. raise io.UnsupportedOperation("Pager does not support iteration")
  751. class _StreamContextAdapter:
  752. """Adapter to make streams work with context manager protocol."""
  753. def __init__(self, stream: TextIO | BinaryIO) -> None:
  754. self.stream = stream
  755. # Expose buffer if it exists
  756. if hasattr(stream, "buffer"):
  757. self.buffer = stream.buffer
  758. else:
  759. self.buffer = stream
  760. def __enter__(self) -> TextIO:
  761. # We only use this with sys.stdout which is TextIO
  762. return self.stream # type: ignore[return-value]
  763. def __exit__(
  764. self,
  765. exc_type: type[BaseException] | None,
  766. exc_val: BaseException | None,
  767. exc_tb: TracebackType | None,
  768. ) -> None:
  769. # For stdout/stderr, we don't close them
  770. pass
  771. def __getattr__(self, name: str) -> object:
  772. return getattr(self.stream, name)
  773. def get_pager(
  774. config: Config | None = None, cmd_name: str | None = None
  775. ) -> "_StreamContextAdapter | Pager":
  776. """Get a pager instance if paging should be used, otherwise return sys.stdout.
  777. Args:
  778. config: Optional config instance (e.g., StackedConfig) to read settings from
  779. cmd_name: Optional command name for per-command pager settings
  780. Returns:
  781. Either a wrapped sys.stdout or a Pager instance (both context managers)
  782. """
  783. # Check global pager disable flag
  784. if getattr(get_pager, "_disabled", False):
  785. return _StreamContextAdapter(sys.stdout)
  786. # Don't page if stdout is not a terminal
  787. if not sys.stdout.isatty():
  788. return _StreamContextAdapter(sys.stdout)
  789. # Priority order for pager command (following git's behavior):
  790. # 1. Check pager.<cmd> config (if cmd_name provided)
  791. # 2. Check environment variables: DULWICH_PAGER, GIT_PAGER, PAGER
  792. # 3. Check core.pager config
  793. # 4. Fallback to common pagers
  794. pager_cmd = None
  795. # 1. Check per-command pager config (pager.<cmd>)
  796. if config and cmd_name:
  797. try:
  798. pager_value = config.get(
  799. ("pager",), cmd_name.encode() if isinstance(cmd_name, str) else cmd_name
  800. )
  801. except KeyError:
  802. pass
  803. else:
  804. if pager_value == b"false":
  805. return _StreamContextAdapter(sys.stdout)
  806. elif pager_value != b"true":
  807. # It's a custom pager command
  808. pager_cmd = (
  809. pager_value.decode()
  810. if isinstance(pager_value, bytes)
  811. else pager_value
  812. )
  813. # 2. Check environment variables
  814. if not pager_cmd:
  815. for env_var in ["DULWICH_PAGER", "GIT_PAGER", "PAGER"]:
  816. pager = os.environ.get(env_var)
  817. if pager:
  818. if pager == "false":
  819. return _StreamContextAdapter(sys.stdout)
  820. pager_cmd = pager
  821. break
  822. # 3. Check core.pager config
  823. if not pager_cmd and config:
  824. try:
  825. core_pager = config.get(("core",), b"pager")
  826. except KeyError:
  827. pass
  828. else:
  829. if core_pager == b"false" or core_pager == b"":
  830. return _StreamContextAdapter(sys.stdout)
  831. pager_cmd = (
  832. core_pager.decode() if isinstance(core_pager, bytes) else core_pager
  833. )
  834. # 4. Fallback to common pagers
  835. if not pager_cmd:
  836. for pager in ["less", "more", "cat"]:
  837. if shutil.which(pager):
  838. if pager == "less":
  839. pager_cmd = "less -FRX" # -F: quit if one screen, -R: raw control chars, -X: no init/deinit
  840. else:
  841. pager_cmd = pager
  842. break
  843. else:
  844. pager_cmd = "cat" # Ultimate fallback
  845. return Pager(pager_cmd)
  846. def disable_pager() -> None:
  847. """Disable pager for this session."""
  848. get_pager._disabled = True # type: ignore[attr-defined]
  849. def enable_pager() -> None:
  850. """Enable pager for this session."""
  851. get_pager._disabled = False # type: ignore[attr-defined]
  852. class Command:
  853. """A Dulwich subcommand."""
  854. def run(self, args: Sequence[str]) -> int | None:
  855. """Run the command."""
  856. raise NotImplementedError(self.run)
  857. class cmd_archive(Command):
  858. """Create an archive of files from a named tree."""
  859. def run(self, args: Sequence[str]) -> None:
  860. """Execute the archive command.
  861. Args:
  862. args: Command line arguments
  863. """
  864. parser = argparse.ArgumentParser()
  865. parser.add_argument(
  866. "--remote",
  867. type=str,
  868. help="Retrieve archive from specified remote repo",
  869. )
  870. parser.add_argument("committish", type=str, nargs="?")
  871. parsed_args = parser.parse_args(args)
  872. if parsed_args.remote:
  873. client, path = get_transport_and_path(parsed_args.remote)
  874. def stdout_write(data: bytes) -> None:
  875. sys.stdout.buffer.write(data)
  876. def stderr_write(data: bytes) -> None:
  877. sys.stderr.buffer.write(data)
  878. client.archive(
  879. path.encode("utf-8") if isinstance(path, str) else path,
  880. parsed_args.committish.encode("utf-8")
  881. if isinstance(parsed_args.committish, str)
  882. else parsed_args.committish,
  883. stdout_write,
  884. write_error=stderr_write,
  885. )
  886. else:
  887. # Use binary buffer for archive output
  888. outstream: BinaryIO = sys.stdout.buffer
  889. errstream: BinaryIO = sys.stderr.buffer
  890. porcelain.archive(
  891. ".",
  892. parsed_args.committish,
  893. outstream=outstream,
  894. errstream=errstream,
  895. )
  896. class cmd_add(Command):
  897. """Add file contents to the index."""
  898. def run(self, argv: Sequence[str]) -> None:
  899. """Execute the add command.
  900. Args:
  901. argv: Command line arguments
  902. """
  903. parser = argparse.ArgumentParser()
  904. parser.add_argument("path", nargs="+")
  905. args = parser.parse_args(argv)
  906. # Convert '.' to None to add all files
  907. paths = args.path
  908. if len(paths) == 1 and paths[0] == ".":
  909. paths = None
  910. porcelain.add(".", paths=paths)
  911. class cmd_annotate(Command):
  912. """Annotate each line in a file with commit information."""
  913. def run(self, argv: Sequence[str]) -> None:
  914. """Execute the annotate command.
  915. Args:
  916. argv: Command line arguments
  917. """
  918. parser = argparse.ArgumentParser()
  919. parser.add_argument("path", help="Path to file to annotate")
  920. parser.add_argument("committish", nargs="?", help="Commit to start from")
  921. args = parser.parse_args(argv)
  922. with Repo(".") as repo:
  923. config = repo.get_config_stack()
  924. with get_pager(config=config, cmd_name="annotate") as outstream:
  925. results = porcelain.annotate(repo, args.path, args.committish)
  926. for (commit, entry), line in results:
  927. # Show shortened commit hash and line content
  928. commit_hash = commit.id[:8]
  929. outstream.write(f"{commit_hash.decode()} {line.decode()}\n")
  930. class cmd_blame(Command):
  931. """Show what revision and author last modified each line of a file."""
  932. def run(self, argv: Sequence[str]) -> None:
  933. """Execute the blame command.
  934. Args:
  935. argv: Command line arguments
  936. """
  937. # blame is an alias for annotate
  938. cmd_annotate().run(argv)
  939. class cmd_rm(Command):
  940. """Remove files from the working tree and from the index."""
  941. def run(self, argv: Sequence[str]) -> None:
  942. """Execute the rm command.
  943. Args:
  944. argv: Command line arguments
  945. """
  946. parser = argparse.ArgumentParser()
  947. parser.add_argument(
  948. "--cached", action="store_true", help="Remove from index only"
  949. )
  950. parser.add_argument("path", type=Path, nargs="+")
  951. args = parser.parse_args(argv)
  952. porcelain.remove(".", paths=args.path, cached=args.cached)
  953. class cmd_mv(Command):
  954. """Move or rename a file, a directory, or a symlink."""
  955. def run(self, argv: Sequence[str]) -> None:
  956. """Execute the mv command.
  957. Args:
  958. argv: Command line arguments
  959. """
  960. parser = argparse.ArgumentParser()
  961. parser.add_argument(
  962. "-f",
  963. "--force",
  964. action="store_true",
  965. help="Force move even if destination exists",
  966. )
  967. parser.add_argument("source", type=Path)
  968. parser.add_argument("destination", type=Path)
  969. args = parser.parse_args(argv)
  970. porcelain.mv(".", args.source, args.destination, force=args.force)
  971. class cmd_fetch_pack(Command):
  972. """Receive missing objects from another repository."""
  973. def run(self, argv: Sequence[str]) -> None:
  974. """Execute the fetch-pack command.
  975. Args:
  976. argv: Command line arguments
  977. """
  978. parser = argparse.ArgumentParser()
  979. parser.add_argument("--all", action="store_true")
  980. parser.add_argument("location", nargs="?", type=str)
  981. parser.add_argument("refs", nargs="*", type=str)
  982. args = parser.parse_args(argv)
  983. client, path = get_transport_and_path(args.location)
  984. r = Repo(".")
  985. if args.all:
  986. determine_wants = r.object_store.determine_wants_all
  987. else:
  988. def determine_wants(
  989. refs: Mapping[bytes, bytes], depth: int | None = None
  990. ) -> list[bytes]:
  991. return [y.encode("utf-8") for y in args.refs if y not in r.object_store]
  992. client.fetch(path.encode("utf-8"), r, determine_wants)
  993. class cmd_fetch(Command):
  994. """Download objects and refs from another repository."""
  995. def run(self, args: Sequence[str]) -> None:
  996. """Execute the fetch command.
  997. Args:
  998. args: Command line arguments
  999. """
  1000. parser = argparse.ArgumentParser()
  1001. # Mutually exclusive group for location vs --all
  1002. location_group = parser.add_mutually_exclusive_group(required=True)
  1003. location_group.add_argument(
  1004. "location", nargs="?", default=None, help="Remote location to fetch from"
  1005. )
  1006. location_group.add_argument(
  1007. "--all", action="store_true", help="Fetch all remotes"
  1008. )
  1009. # Mutually exclusive group for tag handling
  1010. tag_group = parser.add_mutually_exclusive_group()
  1011. tag_group.add_argument(
  1012. "--tags", action="store_true", help="Fetch all tags from remote"
  1013. )
  1014. tag_group.add_argument(
  1015. "--no-tags", action="store_true", help="Don't fetch any tags from remote"
  1016. )
  1017. parser.add_argument(
  1018. "--depth",
  1019. type=int,
  1020. help="Create a shallow clone with a history truncated to the specified number of commits",
  1021. )
  1022. parser.add_argument(
  1023. "--shallow-since",
  1024. type=str,
  1025. help="Deepen or shorten the history of a shallow repository to include all reachable commits after <date>",
  1026. )
  1027. parser.add_argument(
  1028. "--shallow-exclude",
  1029. type=str,
  1030. action="append",
  1031. help="Deepen or shorten the history of a shallow repository to exclude commits reachable from a specified remote branch or tag",
  1032. )
  1033. parsed_args = parser.parse_args(args)
  1034. r = Repo(".")
  1035. def progress(msg: bytes) -> None:
  1036. sys.stdout.buffer.write(msg)
  1037. # Determine include_tags setting
  1038. include_tags = False
  1039. if parsed_args.tags:
  1040. include_tags = True
  1041. elif not parsed_args.no_tags:
  1042. # Default behavior - don't force tag inclusion
  1043. include_tags = False
  1044. if parsed_args.all:
  1045. # Fetch from all remotes
  1046. config = r.get_config()
  1047. remotes = set()
  1048. for section in config.sections():
  1049. if len(section) == 2 and section[0] == b"remote":
  1050. remotes.add(section[1].decode())
  1051. if not remotes:
  1052. logger.warning("No remotes configured")
  1053. return
  1054. for remote_name in sorted(remotes):
  1055. logger.info("Fetching %s", remote_name)
  1056. porcelain.fetch(
  1057. r,
  1058. remote_location=remote_name,
  1059. depth=parsed_args.depth,
  1060. include_tags=include_tags,
  1061. shallow_since=parsed_args.shallow_since,
  1062. shallow_exclude=parsed_args.shallow_exclude,
  1063. )
  1064. else:
  1065. # Fetch from specific location
  1066. porcelain.fetch(
  1067. r,
  1068. remote_location=parsed_args.location,
  1069. depth=parsed_args.depth,
  1070. include_tags=include_tags,
  1071. shallow_since=parsed_args.shallow_since,
  1072. shallow_exclude=parsed_args.shallow_exclude,
  1073. )
  1074. class cmd_for_each_ref(Command):
  1075. """Output information on each ref."""
  1076. def run(self, args: Sequence[str]) -> None:
  1077. """Execute the for-each-ref command.
  1078. Args:
  1079. args: Command line arguments
  1080. """
  1081. parser = argparse.ArgumentParser()
  1082. parser.add_argument("pattern", type=str, nargs="?")
  1083. parsed_args = parser.parse_args(args)
  1084. for sha, object_type, ref in porcelain.for_each_ref(".", parsed_args.pattern):
  1085. logger.info("%s %s\t%s", sha.decode(), object_type.decode(), ref.decode())
  1086. class cmd_fsck(Command):
  1087. """Verify the connectivity and validity of objects in the database."""
  1088. def run(self, args: Sequence[str]) -> None:
  1089. """Execute the fsck command.
  1090. Args:
  1091. args: Command line arguments
  1092. """
  1093. parser = argparse.ArgumentParser()
  1094. parser.parse_args(args)
  1095. for obj, msg in porcelain.fsck("."):
  1096. logger.info("%s: %s", obj.decode() if isinstance(obj, bytes) else obj, msg)
  1097. class cmd_log(Command):
  1098. """Show commit logs."""
  1099. def run(self, args: Sequence[str]) -> None:
  1100. """Execute the log command.
  1101. Args:
  1102. args: Command line arguments
  1103. """
  1104. parser = argparse.ArgumentParser()
  1105. parser.add_argument(
  1106. "--reverse",
  1107. action="store_true",
  1108. help="Reverse order in which entries are printed",
  1109. )
  1110. parser.add_argument(
  1111. "--name-status",
  1112. action="store_true",
  1113. help="Print name/status for each changed file",
  1114. )
  1115. parser.add_argument("paths", nargs="*", help="Paths to show log for")
  1116. parsed_args = parser.parse_args(args)
  1117. with Repo(".") as repo:
  1118. config = repo.get_config_stack()
  1119. with get_pager(config=config, cmd_name="log") as outstream:
  1120. porcelain.log(
  1121. repo,
  1122. paths=parsed_args.paths,
  1123. reverse=parsed_args.reverse,
  1124. name_status=parsed_args.name_status,
  1125. outstream=outstream,
  1126. )
  1127. class cmd_diff(Command):
  1128. """Show changes between commits, commit and working tree, etc."""
  1129. def run(self, args: Sequence[str]) -> None:
  1130. """Execute the diff command.
  1131. Args:
  1132. args: Command line arguments
  1133. """
  1134. parser = argparse.ArgumentParser()
  1135. parser.add_argument(
  1136. "committish", nargs="*", default=[], help="Commits or refs to compare"
  1137. )
  1138. parser.add_argument("--staged", action="store_true", help="Show staged changes")
  1139. parser.add_argument(
  1140. "--cached",
  1141. action="store_true",
  1142. help="Show staged changes (same as --staged)",
  1143. )
  1144. parser.add_argument(
  1145. "--color",
  1146. choices=["always", "never", "auto"],
  1147. default="auto",
  1148. help="Use colored output (requires rich)",
  1149. )
  1150. parser.add_argument(
  1151. "--patience",
  1152. action="store_true",
  1153. help="Use patience diff algorithm",
  1154. )
  1155. parser.add_argument(
  1156. "--diff-algorithm",
  1157. choices=["myers", "patience"],
  1158. default="myers",
  1159. help="Choose a diff algorithm",
  1160. )
  1161. parser.add_argument(
  1162. "--", dest="separator", action="store_true", help=argparse.SUPPRESS
  1163. )
  1164. parser.add_argument("paths", nargs="*", default=[], help="Paths to limit diff")
  1165. # Handle the -- separator for paths
  1166. if "--" in args:
  1167. sep_index = args.index("--")
  1168. parsed_args = parser.parse_args(args[:sep_index])
  1169. parsed_args.paths = args[sep_index + 1 :]
  1170. else:
  1171. parsed_args = parser.parse_args(args)
  1172. # Determine diff algorithm
  1173. diff_algorithm = parsed_args.diff_algorithm
  1174. if parsed_args.patience:
  1175. diff_algorithm = "patience"
  1176. # Determine if we should use color
  1177. def _should_use_color() -> bool:
  1178. if parsed_args.color == "always":
  1179. return True
  1180. elif parsed_args.color == "never":
  1181. return False
  1182. else: # auto
  1183. return sys.stdout.isatty()
  1184. def _create_output_stream(outstream: TextIO) -> BinaryIO:
  1185. """Create output stream, optionally with colorization."""
  1186. if not _should_use_color():
  1187. return outstream.buffer
  1188. from .diff import ColorizedDiffStream
  1189. if not ColorizedDiffStream.is_available():
  1190. if parsed_args.color == "always":
  1191. raise ImportError(
  1192. "Rich is required for colored output. Install with: pip install 'dulwich[colordiff]'"
  1193. )
  1194. else:
  1195. logging.warning(
  1196. "Rich not available, disabling colored output. Install with: pip install 'dulwich[colordiff]'"
  1197. )
  1198. return outstream.buffer
  1199. return ColorizedDiffStream(outstream.buffer)
  1200. with Repo(".") as repo:
  1201. config = repo.get_config_stack()
  1202. with get_pager(config=config, cmd_name="diff") as outstream:
  1203. output_stream = _create_output_stream(outstream)
  1204. try:
  1205. if len(parsed_args.committish) == 0:
  1206. # Show diff for working tree or staged changes
  1207. porcelain.diff(
  1208. repo,
  1209. staged=(parsed_args.staged or parsed_args.cached),
  1210. paths=parsed_args.paths or None,
  1211. outstream=output_stream,
  1212. diff_algorithm=diff_algorithm,
  1213. )
  1214. elif len(parsed_args.committish) == 1:
  1215. # Show diff between working tree and specified commit
  1216. if parsed_args.staged or parsed_args.cached:
  1217. parser.error(
  1218. "--staged/--cached cannot be used with commits"
  1219. )
  1220. porcelain.diff(
  1221. repo,
  1222. commit=parsed_args.committish[0],
  1223. staged=False,
  1224. paths=parsed_args.paths or None,
  1225. outstream=output_stream,
  1226. diff_algorithm=diff_algorithm,
  1227. )
  1228. elif len(parsed_args.committish) == 2:
  1229. # Show diff between two commits
  1230. porcelain.diff(
  1231. repo,
  1232. commit=parsed_args.committish[0],
  1233. commit2=parsed_args.committish[1],
  1234. paths=parsed_args.paths or None,
  1235. outstream=output_stream,
  1236. diff_algorithm=diff_algorithm,
  1237. )
  1238. else:
  1239. parser.error("Too many arguments - specify at most two commits")
  1240. except DiffAlgorithmNotAvailable as e:
  1241. sys.stderr.write(f"fatal: {e}\n")
  1242. sys.exit(1)
  1243. # Flush any remaining output
  1244. if hasattr(output_stream, "flush"):
  1245. output_stream.flush()
  1246. class cmd_dump_pack(Command):
  1247. """Dump the contents of a pack file for debugging."""
  1248. def run(self, args: Sequence[str]) -> None:
  1249. """Execute the dump-pack command.
  1250. Args:
  1251. args: Command line arguments
  1252. """
  1253. parser = argparse.ArgumentParser()
  1254. parser.add_argument("filename", help="Pack file to dump")
  1255. parsed_args = parser.parse_args(args)
  1256. basename, _ = os.path.splitext(parsed_args.filename)
  1257. x = Pack(basename)
  1258. logger.info("Object names checksum: %s", x.name().decode("ascii", "replace"))
  1259. logger.info("Checksum: %r", sha_to_hex(x.get_stored_checksum()))
  1260. x.check()
  1261. logger.info("Length: %d", len(x))
  1262. for name in x:
  1263. try:
  1264. logger.info("\t%s", x[name])
  1265. except KeyError as k:
  1266. logger.error(
  1267. "\t%s: Unable to resolve base %r",
  1268. name.decode("ascii", "replace"),
  1269. k,
  1270. )
  1271. except ApplyDeltaError as e:
  1272. logger.error(
  1273. "\t%s: Unable to apply delta: %r",
  1274. name.decode("ascii", "replace"),
  1275. e,
  1276. )
  1277. class cmd_dump_index(Command):
  1278. """Show information about a pack index file."""
  1279. def run(self, args: Sequence[str]) -> None:
  1280. """Execute the dump-index command.
  1281. Args:
  1282. args: Command line arguments
  1283. """
  1284. parser = argparse.ArgumentParser()
  1285. parser.add_argument("filename", help="Index file to dump")
  1286. parsed_args = parser.parse_args(args)
  1287. idx = Index(parsed_args.filename)
  1288. for o in idx:
  1289. logger.info("%s %s", o, idx[o])
  1290. class cmd_interpret_trailers(Command):
  1291. """Add or parse structured information in commit messages."""
  1292. def run(self, args: Sequence[str]) -> None:
  1293. """Execute the interpret-trailers command.
  1294. Args:
  1295. args: Command line arguments
  1296. """
  1297. parser = argparse.ArgumentParser()
  1298. parser.add_argument(
  1299. "file",
  1300. nargs="?",
  1301. help="File to read message from. If not specified, reads from stdin.",
  1302. )
  1303. parser.add_argument(
  1304. "--trailer",
  1305. action="append",
  1306. dest="trailers",
  1307. metavar="<token>[(=|:)<value>]",
  1308. help="Trailer to add. Can be specified multiple times.",
  1309. )
  1310. parser.add_argument(
  1311. "--trim-empty",
  1312. action="store_true",
  1313. help="Remove trailers with empty values",
  1314. )
  1315. parser.add_argument(
  1316. "--only-trailers",
  1317. action="store_true",
  1318. help="Output only the trailers, not the message body",
  1319. )
  1320. parser.add_argument(
  1321. "--only-input",
  1322. action="store_true",
  1323. help="Don't add new trailers, only parse existing ones",
  1324. )
  1325. parser.add_argument(
  1326. "--unfold", action="store_true", help="Join multiline values into one line"
  1327. )
  1328. parser.add_argument(
  1329. "--parse",
  1330. action="store_true",
  1331. help="Shorthand for --only-trailers --only-input --unfold",
  1332. )
  1333. parser.add_argument(
  1334. "--where",
  1335. choices=["end", "start", "after", "before"],
  1336. default="end",
  1337. help="Where to place new trailers",
  1338. )
  1339. parser.add_argument(
  1340. "--if-exists",
  1341. choices=[
  1342. "add",
  1343. "replace",
  1344. "addIfDifferent",
  1345. "addIfDifferentNeighbor",
  1346. "doNothing",
  1347. ],
  1348. default="addIfDifferentNeighbor",
  1349. help="Action if trailer already exists",
  1350. )
  1351. parser.add_argument(
  1352. "--if-missing",
  1353. choices=["add", "doNothing"],
  1354. default="add",
  1355. help="Action if trailer is missing",
  1356. )
  1357. parsed_args = parser.parse_args(args)
  1358. # Read message from file or stdin
  1359. if parsed_args.file:
  1360. with open(parsed_args.file, "rb") as f:
  1361. message = f.read()
  1362. else:
  1363. message = sys.stdin.buffer.read()
  1364. # Parse trailer arguments
  1365. trailer_list = []
  1366. if parsed_args.trailers:
  1367. for trailer_spec in parsed_args.trailers:
  1368. # Parse "key:value" or "key=value" or just "key"
  1369. if ":" in trailer_spec:
  1370. key, value = trailer_spec.split(":", 1)
  1371. elif "=" in trailer_spec:
  1372. key, value = trailer_spec.split("=", 1)
  1373. else:
  1374. key = trailer_spec
  1375. value = ""
  1376. trailer_list.append((key.strip(), value.strip()))
  1377. # Call interpret_trailers
  1378. result = porcelain.interpret_trailers(
  1379. message,
  1380. trailers=trailer_list if trailer_list else None,
  1381. trim_empty=parsed_args.trim_empty,
  1382. only_trailers=parsed_args.only_trailers,
  1383. only_input=parsed_args.only_input,
  1384. unfold=parsed_args.unfold,
  1385. parse=parsed_args.parse,
  1386. where=parsed_args.where,
  1387. if_exists=parsed_args.if_exists,
  1388. if_missing=parsed_args.if_missing,
  1389. )
  1390. # Output result
  1391. sys.stdout.buffer.write(result)
  1392. class cmd_stripspace(Command):
  1393. """Remove unnecessary whitespace from text."""
  1394. def run(self, args: Sequence[str]) -> None:
  1395. """Execute the stripspace command.
  1396. Args:
  1397. args: Command line arguments
  1398. """
  1399. parser = argparse.ArgumentParser()
  1400. parser.add_argument(
  1401. "file",
  1402. nargs="?",
  1403. help="File to read text from. If not specified, reads from stdin.",
  1404. )
  1405. parser.add_argument(
  1406. "-s",
  1407. "--strip-comments",
  1408. action="store_true",
  1409. help="Strip lines that begin with comment character",
  1410. )
  1411. parser.add_argument(
  1412. "-c",
  1413. "--comment-lines",
  1414. action="store_true",
  1415. help="Prepend comment character to each line",
  1416. )
  1417. parser.add_argument(
  1418. "--comment-char",
  1419. default="#",
  1420. help="Comment character to use (default: #)",
  1421. )
  1422. parsed_args = parser.parse_args(args)
  1423. # Read text from file or stdin
  1424. if parsed_args.file:
  1425. with open(parsed_args.file, "rb") as f:
  1426. text = f.read()
  1427. else:
  1428. text = sys.stdin.buffer.read()
  1429. # Call stripspace
  1430. result = porcelain.stripspace(
  1431. text,
  1432. strip_comments=parsed_args.strip_comments,
  1433. comment_char=parsed_args.comment_char,
  1434. comment_lines=parsed_args.comment_lines,
  1435. )
  1436. # Output result
  1437. sys.stdout.buffer.write(result)
  1438. class cmd_column(Command):
  1439. """Display data in columns."""
  1440. def run(self, args: Sequence[str]) -> None:
  1441. """Execute the column command.
  1442. Args:
  1443. args: Command line arguments
  1444. """
  1445. parser = argparse.ArgumentParser(
  1446. description="Format input data into columns for better readability"
  1447. )
  1448. parser.add_argument(
  1449. "--mode",
  1450. default="column",
  1451. help=(
  1452. "Layout mode: 'column' (fill columns first), 'row' (fill rows first), "
  1453. "'plain' (one column). Add ',dense' for unequal column widths, "
  1454. "',nodense' for equal widths (default: column)"
  1455. ),
  1456. )
  1457. parser.add_argument(
  1458. "--width",
  1459. type=int,
  1460. help="Terminal width (default: auto-detect)",
  1461. )
  1462. parser.add_argument(
  1463. "--indent",
  1464. default="",
  1465. help="String to prepend to each line (default: empty)",
  1466. )
  1467. parser.add_argument(
  1468. "--nl",
  1469. default="\n",
  1470. help="String to append to each line, including newline (default: \\n)",
  1471. )
  1472. parser.add_argument(
  1473. "--padding",
  1474. type=int,
  1475. default=1,
  1476. help="Number of spaces between columns (default: 1)",
  1477. )
  1478. parsed_args = parser.parse_args(args)
  1479. # Read lines from stdin
  1480. lines = []
  1481. for line in sys.stdin:
  1482. # Strip the newline but keep the content
  1483. lines.append(line.rstrip("\n\r"))
  1484. # Format and output
  1485. result = format_columns(
  1486. lines,
  1487. width=parsed_args.width,
  1488. mode=parsed_args.mode,
  1489. padding=parsed_args.padding,
  1490. indent=parsed_args.indent,
  1491. nl=parsed_args.nl,
  1492. )
  1493. sys.stdout.write(result)
  1494. class cmd_init(Command):
  1495. """Create an empty Git repository or reinitialize an existing one."""
  1496. def run(self, args: Sequence[str]) -> None:
  1497. """Execute the init command.
  1498. Args:
  1499. args: Command line arguments
  1500. """
  1501. parser = argparse.ArgumentParser()
  1502. parser.add_argument(
  1503. "--bare", action="store_true", help="Create a bare repository"
  1504. )
  1505. parser.add_argument(
  1506. "path", nargs="?", default=os.getcwd(), help="Repository path"
  1507. )
  1508. parsed_args = parser.parse_args(args)
  1509. porcelain.init(parsed_args.path, bare=parsed_args.bare)
  1510. class cmd_clone(Command):
  1511. """Clone a repository into a new directory."""
  1512. def run(self, args: Sequence[str]) -> None:
  1513. """Execute the clone command.
  1514. Args:
  1515. args: Command line arguments
  1516. """
  1517. parser = argparse.ArgumentParser()
  1518. parser.add_argument(
  1519. "--bare",
  1520. help="Whether to create a bare repository.",
  1521. action="store_true",
  1522. )
  1523. parser.add_argument("--depth", type=int, help="Depth at which to fetch")
  1524. parser.add_argument(
  1525. "-b",
  1526. "--branch",
  1527. type=str,
  1528. help="Check out branch instead of branch pointed to by remote HEAD",
  1529. )
  1530. parser.add_argument(
  1531. "--refspec",
  1532. type=str,
  1533. help="References to fetch",
  1534. action="append",
  1535. )
  1536. parser.add_argument(
  1537. "--filter",
  1538. dest="filter_spec",
  1539. type=str,
  1540. help="git-rev-list-style object filter",
  1541. )
  1542. parser.add_argument(
  1543. "--protocol",
  1544. type=int,
  1545. help="Git protocol version to use",
  1546. )
  1547. parser.add_argument(
  1548. "--recurse-submodules",
  1549. action="store_true",
  1550. help="Initialize and clone submodules",
  1551. )
  1552. parser.add_argument("source", help="Repository to clone from")
  1553. parser.add_argument("target", nargs="?", help="Directory to clone into")
  1554. parsed_args = parser.parse_args(args)
  1555. try:
  1556. porcelain.clone(
  1557. parsed_args.source,
  1558. parsed_args.target,
  1559. bare=parsed_args.bare,
  1560. depth=parsed_args.depth,
  1561. branch=parsed_args.branch,
  1562. refspec=parsed_args.refspec,
  1563. filter_spec=parsed_args.filter_spec,
  1564. protocol_version=parsed_args.protocol,
  1565. recurse_submodules=parsed_args.recurse_submodules,
  1566. )
  1567. except GitProtocolError as e:
  1568. logging.exception(e)
  1569. def _get_commit_message_with_template(
  1570. initial_message: bytes | None,
  1571. repo: Repo | None = None,
  1572. commit: Commit | None = None,
  1573. ) -> bytes:
  1574. """Get commit message with an initial message template."""
  1575. # Start with the initial message
  1576. template = initial_message or b""
  1577. if template and not template.endswith(b"\n"):
  1578. template += b"\n"
  1579. template += b"\n"
  1580. template += b"# Please enter the commit message for your changes. Lines starting\n"
  1581. template += b"# with '#' will be ignored, and an empty message aborts the commit.\n"
  1582. template += b"#\n"
  1583. # Add branch info if repo is provided
  1584. if repo:
  1585. try:
  1586. ref_names, _ref_sha = repo.refs.follow(b"HEAD")
  1587. ref_path = ref_names[-1] # Get the final reference
  1588. if ref_path.startswith(b"refs/heads/"):
  1589. branch = ref_path[11:] # Remove 'refs/heads/' prefix
  1590. else:
  1591. branch = ref_path
  1592. template += b"# On branch %s\n" % branch
  1593. except (KeyError, IndexError):
  1594. template += b"# On branch (unknown)\n"
  1595. template += b"#\n"
  1596. template += b"# Changes to be committed:\n"
  1597. # Launch editor
  1598. content = launch_editor(template)
  1599. # Remove comment lines and strip
  1600. lines = content.split(b"\n")
  1601. message_lines = [line for line in lines if not line.strip().startswith(b"#")]
  1602. message = b"\n".join(message_lines).strip()
  1603. if not message:
  1604. raise CommitMessageError("Aborting commit due to empty commit message")
  1605. return message
  1606. class cmd_config(Command):
  1607. """Get and set repository or global options."""
  1608. def run(self, args: Sequence[str]) -> int | None:
  1609. """Execute the config command.
  1610. Args:
  1611. args: Command line arguments
  1612. """
  1613. parser = argparse.ArgumentParser()
  1614. parser.add_argument(
  1615. "--global",
  1616. dest="global_config",
  1617. action="store_true",
  1618. help="Use global config file",
  1619. )
  1620. parser.add_argument(
  1621. "--local",
  1622. action="store_true",
  1623. help="Use repository config file (default)",
  1624. )
  1625. parser.add_argument(
  1626. "-l",
  1627. "--list",
  1628. action="store_true",
  1629. help="List all variables",
  1630. )
  1631. parser.add_argument(
  1632. "--unset",
  1633. action="store_true",
  1634. help="Remove a variable",
  1635. )
  1636. parser.add_argument(
  1637. "--unset-all",
  1638. action="store_true",
  1639. help="Remove all matches for a variable",
  1640. )
  1641. parser.add_argument(
  1642. "--get-all",
  1643. action="store_true",
  1644. help="Get all values for a multivar",
  1645. )
  1646. parser.add_argument(
  1647. "key",
  1648. nargs="?",
  1649. help="Config key (e.g., user.name)",
  1650. )
  1651. parser.add_argument(
  1652. "value",
  1653. nargs="?",
  1654. help="Config value to set",
  1655. )
  1656. parsed_args = parser.parse_args(args)
  1657. # Determine which config file to use
  1658. if parsed_args.global_config:
  1659. # Use global config file
  1660. config_path = os.path.expanduser("~/.gitconfig")
  1661. try:
  1662. from .config import ConfigFile
  1663. config = ConfigFile.from_path(config_path)
  1664. except FileNotFoundError:
  1665. from .config import ConfigFile
  1666. config = ConfigFile()
  1667. config.path = config_path
  1668. else:
  1669. # Use local repository config (default)
  1670. try:
  1671. repo = Repo(".")
  1672. config = repo.get_config()
  1673. except NotGitRepository:
  1674. logger.error("error: not a git repository")
  1675. return 1
  1676. # Handle --list
  1677. if parsed_args.list:
  1678. for section in config.sections():
  1679. for key, value in config.items(section):
  1680. section_str = ".".join(
  1681. s.decode("utf-8") if isinstance(s, bytes) else s
  1682. for s in section
  1683. )
  1684. key_str = key.decode("utf-8") if isinstance(key, bytes) else key
  1685. value_str = (
  1686. value.decode("utf-8") if isinstance(value, bytes) else value
  1687. )
  1688. print(f"{section_str}.{key_str}={value_str}")
  1689. return 0
  1690. # Handle --unset or --unset-all
  1691. if parsed_args.unset or parsed_args.unset_all:
  1692. if not parsed_args.key:
  1693. logger.error("error: key is required for --unset")
  1694. return 1
  1695. # Parse the key (e.g., "user.name" or "remote.origin.url")
  1696. parts = parsed_args.key.split(".")
  1697. if len(parts) < 2:
  1698. logger.error("error: invalid key format")
  1699. return 1
  1700. if len(parts) == 2:
  1701. section = (parts[0],)
  1702. name = parts[1]
  1703. else:
  1704. # For keys like "remote.origin.url", section is ("remote", "origin")
  1705. section = tuple(parts[:-1])
  1706. name = parts[-1]
  1707. try:
  1708. # Check if the key exists first
  1709. try:
  1710. config.get(section, name)
  1711. except KeyError:
  1712. logger.error(f"error: key '{parsed_args.key}' not found")
  1713. return 1
  1714. # Delete the configuration key using ConfigDict's delete method
  1715. section_bytes = tuple(
  1716. s.encode("utf-8") if isinstance(s, str) else s for s in section
  1717. )
  1718. name_bytes = name.encode("utf-8") if isinstance(name, str) else name
  1719. section_dict = config._values.get(section_bytes)
  1720. if section_dict:
  1721. del section_dict[name_bytes]
  1722. config.write_to_path()
  1723. else:
  1724. logger.error(f"error: key '{parsed_args.key}' not found")
  1725. return 1
  1726. except Exception as e:
  1727. logger.error(f"error: {e}")
  1728. return 1
  1729. return 0
  1730. # Handle --get-all
  1731. if parsed_args.get_all:
  1732. if not parsed_args.key:
  1733. logger.error("error: key is required for --get-all")
  1734. return 1
  1735. parts = parsed_args.key.split(".")
  1736. if len(parts) < 2:
  1737. logger.error("error: invalid key format")
  1738. return 1
  1739. if len(parts) == 2:
  1740. section = (parts[0],)
  1741. name = parts[1]
  1742. else:
  1743. section = tuple(parts[:-1])
  1744. name = parts[-1]
  1745. try:
  1746. for value in config.get_multivar(section, name):
  1747. value_str = (
  1748. value.decode("utf-8") if isinstance(value, bytes) else value
  1749. )
  1750. print(value_str)
  1751. return 0
  1752. except KeyError:
  1753. return 1
  1754. # Handle get (no value provided)
  1755. if parsed_args.key and not parsed_args.value:
  1756. parts = parsed_args.key.split(".")
  1757. if len(parts) < 2:
  1758. logger.error("error: invalid key format")
  1759. return 1
  1760. if len(parts) == 2:
  1761. section = (parts[0],)
  1762. name = parts[1]
  1763. else:
  1764. # For keys like "remote.origin.url", section is ("remote", "origin")
  1765. section = tuple(parts[:-1])
  1766. name = parts[-1]
  1767. try:
  1768. value = config.get(section, name)
  1769. value_str = value.decode("utf-8") if isinstance(value, bytes) else value
  1770. print(value_str)
  1771. return 0
  1772. except KeyError:
  1773. return 1
  1774. # Handle set (key and value provided)
  1775. if parsed_args.key and parsed_args.value:
  1776. parts = parsed_args.key.split(".")
  1777. if len(parts) < 2:
  1778. logger.error("error: invalid key format")
  1779. return 1
  1780. if len(parts) == 2:
  1781. section = (parts[0],)
  1782. name = parts[1]
  1783. else:
  1784. # For keys like "remote.origin.url", section is ("remote", "origin")
  1785. section = tuple(parts[:-1])
  1786. name = parts[-1]
  1787. config.set(section, name, parsed_args.value)
  1788. if parsed_args.global_config:
  1789. config.write_to_path()
  1790. else:
  1791. config.write_to_path()
  1792. return 0
  1793. # No action specified
  1794. parser.print_help()
  1795. return 1
  1796. class cmd_commit(Command):
  1797. """Record changes to the repository."""
  1798. def run(self, args: Sequence[str]) -> int | None:
  1799. """Execute the commit command.
  1800. Args:
  1801. args: Command line arguments
  1802. """
  1803. parser = argparse.ArgumentParser()
  1804. parser.add_argument("--message", "-m", help="Commit message")
  1805. parser.add_argument(
  1806. "-a",
  1807. "--all",
  1808. action="store_true",
  1809. help="Automatically stage all tracked files that have been modified",
  1810. )
  1811. parser.add_argument(
  1812. "--amend",
  1813. action="store_true",
  1814. help="Replace the tip of the current branch by creating a new commit",
  1815. )
  1816. parsed_args = parser.parse_args(args)
  1817. message: bytes | str | Callable[[Repo | None, Commit | None], bytes]
  1818. if parsed_args.message:
  1819. message = parsed_args.message
  1820. elif parsed_args.amend:
  1821. # For amend, create a callable that opens editor with original message pre-populated
  1822. def get_amend_message(repo: Repo | None, commit: Commit | None) -> bytes:
  1823. # Get the original commit message from current HEAD
  1824. assert repo is not None
  1825. try:
  1826. head_commit = repo[repo.head()]
  1827. assert isinstance(head_commit, Commit)
  1828. original_message = head_commit.message
  1829. except KeyError:
  1830. original_message = b""
  1831. # Open editor with original message
  1832. return _get_commit_message_with_template(original_message, repo, commit)
  1833. message = get_amend_message
  1834. else:
  1835. # For regular commits, use empty template
  1836. def get_regular_message(repo: Repo | None, commit: Commit | None) -> bytes:
  1837. return _get_commit_message_with_template(b"", repo, commit)
  1838. message = get_regular_message
  1839. try:
  1840. porcelain.commit(
  1841. ".", message=message, all=parsed_args.all, amend=parsed_args.amend
  1842. )
  1843. except CommitMessageError as e:
  1844. logging.exception(e)
  1845. return 1
  1846. return None
  1847. class cmd_commit_tree(Command):
  1848. """Create a new commit object from a tree."""
  1849. def run(self, args: Sequence[str]) -> None:
  1850. """Execute the commit-tree command.
  1851. Args:
  1852. args: Command line arguments
  1853. """
  1854. parser = argparse.ArgumentParser()
  1855. parser.add_argument("--message", "-m", required=True, help="Commit message")
  1856. parser.add_argument("tree", help="Tree SHA to commit")
  1857. parsed_args = parser.parse_args(args)
  1858. porcelain.commit_tree(".", tree=parsed_args.tree, message=parsed_args.message)
  1859. class cmd_update_server_info(Command):
  1860. """Update auxiliary info file to help dumb servers."""
  1861. def run(self, args: Sequence[str]) -> None:
  1862. """Execute the update-server-info command.
  1863. Args:
  1864. args: Command line arguments
  1865. """
  1866. porcelain.update_server_info(".")
  1867. class cmd_symbolic_ref(Command):
  1868. """Read, modify and delete symbolic refs."""
  1869. def run(self, args: Sequence[str]) -> int | None:
  1870. """Execute the symbolic-ref command.
  1871. Args:
  1872. args: Command line arguments
  1873. """
  1874. parser = argparse.ArgumentParser()
  1875. parser.add_argument("name", help="Symbolic reference name")
  1876. parser.add_argument("ref", nargs="?", help="Target reference")
  1877. parser.add_argument("--force", action="store_true", help="Force update")
  1878. parsed_args = parser.parse_args(args)
  1879. # If ref is provided, we're setting; otherwise we're reading
  1880. if parsed_args.ref:
  1881. # Set symbolic reference
  1882. from .repo import Repo
  1883. with Repo(".") as repo:
  1884. repo.refs.set_symbolic_ref(
  1885. parsed_args.name.encode(), parsed_args.ref.encode()
  1886. )
  1887. return 0
  1888. else:
  1889. # Read symbolic reference
  1890. from .repo import Repo
  1891. with Repo(".") as repo:
  1892. try:
  1893. target = repo.refs.read_ref(parsed_args.name.encode())
  1894. if target is None:
  1895. logger.error(
  1896. "fatal: ref '%s' is not a symbolic ref", parsed_args.name
  1897. )
  1898. return 1
  1899. elif target.startswith(b"ref: "):
  1900. logger.info(target[5:].decode())
  1901. else:
  1902. logger.info(target.decode())
  1903. return 0
  1904. except KeyError:
  1905. logger.error(
  1906. "fatal: ref '%s' is not a symbolic ref", parsed_args.name
  1907. )
  1908. return 1
  1909. class cmd_pack_refs(Command):
  1910. """Pack heads and tags for efficient repository access."""
  1911. def run(self, argv: Sequence[str]) -> None:
  1912. """Execute the pack-refs command.
  1913. Args:
  1914. argv: Command line arguments
  1915. """
  1916. parser = argparse.ArgumentParser()
  1917. parser.add_argument("--all", action="store_true")
  1918. # ignored, we never prune
  1919. parser.add_argument("--no-prune", action="store_true")
  1920. args = parser.parse_args(argv)
  1921. porcelain.pack_refs(".", all=args.all)
  1922. class cmd_var(Command):
  1923. """Display Git logical variables."""
  1924. def run(self, argv: Sequence[str]) -> int | None:
  1925. """Execute the var command.
  1926. Args:
  1927. argv: Command line arguments
  1928. """
  1929. parser = argparse.ArgumentParser()
  1930. parser.add_argument(
  1931. "variable",
  1932. nargs="?",
  1933. help="Variable to query (e.g., GIT_AUTHOR_IDENT)",
  1934. )
  1935. parser.add_argument(
  1936. "-l",
  1937. "--list",
  1938. action="store_true",
  1939. help="List all variables",
  1940. )
  1941. args = parser.parse_args(argv)
  1942. if args.list:
  1943. # List all variables
  1944. variables = porcelain.var_list(".")
  1945. for key, value in sorted(variables.items()):
  1946. print(f"{key}={value}")
  1947. return 0
  1948. elif args.variable:
  1949. # Query specific variable
  1950. try:
  1951. value = porcelain.var(".", variable=args.variable)
  1952. print(value)
  1953. return 0
  1954. except KeyError:
  1955. logger.error("error: variable '%s' has no value", args.variable)
  1956. return 1
  1957. else:
  1958. # No arguments - print error
  1959. logger.error("error: variable or -l is required")
  1960. parser.print_help()
  1961. return 1
  1962. class cmd_show(Command):
  1963. """Show various types of objects."""
  1964. def run(self, argv: Sequence[str]) -> None:
  1965. """Execute the show command.
  1966. Args:
  1967. argv: Command line arguments
  1968. """
  1969. parser = argparse.ArgumentParser()
  1970. parser.add_argument("objectish", type=str, nargs="*")
  1971. parser.add_argument(
  1972. "--color",
  1973. choices=["always", "never", "auto"],
  1974. default="auto",
  1975. help="Use colored output (requires rich)",
  1976. )
  1977. args = parser.parse_args(argv)
  1978. # Determine if we should use color
  1979. def _should_use_color() -> bool:
  1980. if args.color == "always":
  1981. return True
  1982. elif args.color == "never":
  1983. return False
  1984. else: # auto
  1985. return sys.stdout.isatty()
  1986. def _create_output_stream(outstream: TextIO) -> TextIO:
  1987. """Create output stream, optionally with colorization."""
  1988. if not _should_use_color():
  1989. return outstream
  1990. from .diff import ColorizedDiffStream
  1991. if not ColorizedDiffStream.is_available():
  1992. if args.color == "always":
  1993. raise ImportError(
  1994. "Rich is required for colored output. Install with: pip install 'dulwich[colordiff]'"
  1995. )
  1996. else:
  1997. logging.warning(
  1998. "Rich not available, disabling colored output. Install with: pip install 'dulwich[colordiff]'"
  1999. )
  2000. return outstream
  2001. # Wrap the ColorizedDiffStream (BinaryIO) back to TextIO
  2002. import io
  2003. colorized = ColorizedDiffStream(outstream.buffer)
  2004. return io.TextIOWrapper(colorized, encoding="utf-8", line_buffering=True)
  2005. with Repo(".") as repo:
  2006. config = repo.get_config_stack()
  2007. with get_pager(config=config, cmd_name="show") as outstream:
  2008. output_stream = _create_output_stream(outstream)
  2009. porcelain.show(repo, args.objectish or None, outstream=output_stream)
  2010. class cmd_show_ref(Command):
  2011. """List references in a local repository."""
  2012. def run(self, args: Sequence[str]) -> int | None:
  2013. """Execute the show-ref command.
  2014. Args:
  2015. args: Command line arguments
  2016. Returns:
  2017. Exit code (0 for success, 1 for error/no matches, 2 for missing ref with --exists)
  2018. """
  2019. parser = argparse.ArgumentParser()
  2020. parser.add_argument(
  2021. "--head",
  2022. action="store_true",
  2023. help="Show the HEAD reference",
  2024. )
  2025. parser.add_argument(
  2026. "--branches",
  2027. action="store_true",
  2028. help="Limit to local branches",
  2029. )
  2030. parser.add_argument(
  2031. "--tags",
  2032. action="store_true",
  2033. help="Limit to local tags",
  2034. )
  2035. parser.add_argument(
  2036. "-d",
  2037. "--dereference",
  2038. action="store_true",
  2039. help="Dereference tags into object IDs",
  2040. )
  2041. parser.add_argument(
  2042. "-s",
  2043. "--hash",
  2044. nargs="?",
  2045. const=40,
  2046. type=int,
  2047. metavar="n",
  2048. help="Only show the OID, not the reference name",
  2049. )
  2050. parser.add_argument(
  2051. "--abbrev",
  2052. nargs="?",
  2053. const=7,
  2054. type=int,
  2055. metavar="n",
  2056. help="Abbreviate the object name",
  2057. )
  2058. parser.add_argument(
  2059. "--verify",
  2060. action="store_true",
  2061. help="Enable stricter reference checking (exact path match)",
  2062. )
  2063. parser.add_argument(
  2064. "--exists",
  2065. action="store_true",
  2066. help="Check whether the given reference exists",
  2067. )
  2068. parser.add_argument(
  2069. "-q",
  2070. "--quiet",
  2071. action="store_true",
  2072. help="Do not print any results to stdout",
  2073. )
  2074. parser.add_argument(
  2075. "patterns",
  2076. nargs="*",
  2077. help="Show references matching patterns",
  2078. )
  2079. parsed_args = parser.parse_args(args)
  2080. # Handle --exists mode
  2081. if parsed_args.exists:
  2082. if not parsed_args.patterns or len(parsed_args.patterns) != 1:
  2083. logger.error("--exists requires exactly one reference argument")
  2084. return 1
  2085. try:
  2086. with Repo(".") as repo:
  2087. repo_refs = repo.get_refs()
  2088. pattern_bytes = os.fsencode(parsed_args.patterns[0])
  2089. if pattern_bytes in repo_refs:
  2090. return 0 # Reference exists
  2091. else:
  2092. return 2 # Reference missing
  2093. except (NotGitRepository, OSError, FileFormatException) as e:
  2094. logger.error(f"Error looking up reference: {e}")
  2095. return 1 # Error looking up reference
  2096. # Regular show-ref mode
  2097. try:
  2098. matched_refs = porcelain.show_ref(
  2099. ".",
  2100. patterns=parsed_args.patterns if parsed_args.patterns else None,
  2101. head=parsed_args.head,
  2102. branches=parsed_args.branches,
  2103. tags=parsed_args.tags,
  2104. dereference=parsed_args.dereference,
  2105. verify=parsed_args.verify,
  2106. )
  2107. except (NotGitRepository, OSError, FileFormatException) as e:
  2108. logger.error(f"Error: {e}")
  2109. return 1
  2110. # Return error if no matches found (unless quiet)
  2111. if not matched_refs:
  2112. if parsed_args.verify and not parsed_args.quiet:
  2113. logger.error("error: no matching refs found")
  2114. return 1
  2115. # Output results
  2116. if not parsed_args.quiet:
  2117. abbrev_len = parsed_args.abbrev if parsed_args.abbrev else 40
  2118. hash_only = parsed_args.hash is not None
  2119. if hash_only and parsed_args.hash:
  2120. abbrev_len = parsed_args.hash
  2121. for sha, ref in matched_refs:
  2122. sha_str = sha.decode()
  2123. if abbrev_len < 40:
  2124. sha_str = sha_str[:abbrev_len]
  2125. if hash_only:
  2126. logger.info(sha_str)
  2127. else:
  2128. logger.info(f"{sha_str} {ref.decode()}")
  2129. return 0
  2130. class cmd_show_branch(Command):
  2131. """Show branches and their commits."""
  2132. def run(self, args: Sequence[str]) -> int | None:
  2133. """Execute the show-branch command.
  2134. Args:
  2135. args: Command line arguments
  2136. Returns:
  2137. Exit code (0 for success, 1 for error)
  2138. """
  2139. parser = argparse.ArgumentParser()
  2140. parser.add_argument(
  2141. "-r",
  2142. "--remotes",
  2143. action="store_true",
  2144. help="Show remote-tracking branches",
  2145. )
  2146. parser.add_argument(
  2147. "-a",
  2148. "--all",
  2149. dest="all_branches",
  2150. action="store_true",
  2151. help="Show both remote-tracking and local branches",
  2152. )
  2153. parser.add_argument(
  2154. "--current",
  2155. action="store_true",
  2156. help="Include current branch if not given on command line",
  2157. )
  2158. parser.add_argument(
  2159. "--topo-order",
  2160. dest="topo_order",
  2161. action="store_true",
  2162. help="Show commits in topological order",
  2163. )
  2164. parser.add_argument(
  2165. "--date-order",
  2166. action="store_true",
  2167. help="Show commits in date order (default)",
  2168. )
  2169. parser.add_argument(
  2170. "--more",
  2171. type=int,
  2172. metavar="n",
  2173. help="Show n more commits beyond common ancestor",
  2174. )
  2175. parser.add_argument(
  2176. "--list",
  2177. dest="list_branches",
  2178. action="store_true",
  2179. help="Show only branch names and their tip commits",
  2180. )
  2181. parser.add_argument(
  2182. "--independent",
  2183. dest="independent_branches",
  2184. action="store_true",
  2185. help="Show only branches not reachable from any other",
  2186. )
  2187. parser.add_argument(
  2188. "--merge-base",
  2189. dest="merge_base",
  2190. action="store_true",
  2191. help="Show merge base of specified branches",
  2192. )
  2193. parser.add_argument(
  2194. "branches",
  2195. nargs="*",
  2196. help="Branches to show (default: all local branches)",
  2197. )
  2198. parsed_args = parser.parse_args(args)
  2199. try:
  2200. output_lines = porcelain.show_branch(
  2201. ".",
  2202. branches=parsed_args.branches if parsed_args.branches else None,
  2203. all_branches=parsed_args.all_branches,
  2204. remotes=parsed_args.remotes,
  2205. current=parsed_args.current,
  2206. topo_order=parsed_args.topo_order,
  2207. more=parsed_args.more,
  2208. list_branches=parsed_args.list_branches,
  2209. independent_branches=parsed_args.independent_branches,
  2210. merge_base=parsed_args.merge_base,
  2211. )
  2212. except (NotGitRepository, OSError, FileFormatException) as e:
  2213. logger.error(f"Error: {e}")
  2214. return 1
  2215. # Output results
  2216. for line in output_lines:
  2217. logger.info(line)
  2218. return 0
  2219. class cmd_diff_tree(Command):
  2220. """Compare the content and mode of trees."""
  2221. def run(self, args: Sequence[str]) -> None:
  2222. """Execute the diff-tree command.
  2223. Args:
  2224. args: Command line arguments
  2225. """
  2226. parser = argparse.ArgumentParser()
  2227. parser.add_argument("old_tree", help="Old tree SHA")
  2228. parser.add_argument("new_tree", help="New tree SHA")
  2229. parsed_args = parser.parse_args(args)
  2230. porcelain.diff_tree(".", parsed_args.old_tree, parsed_args.new_tree)
  2231. class cmd_rev_list(Command):
  2232. """List commit objects in reverse chronological order."""
  2233. def run(self, args: Sequence[str]) -> None:
  2234. """Execute the rev-list command.
  2235. Args:
  2236. args: Command line arguments
  2237. """
  2238. parser = argparse.ArgumentParser()
  2239. parser.add_argument("commits", nargs="+", help="Commit IDs to list")
  2240. parsed_args = parser.parse_args(args)
  2241. porcelain.rev_list(".", parsed_args.commits)
  2242. class cmd_tag(Command):
  2243. """Create, list, delete or verify a tag object."""
  2244. def run(self, args: Sequence[str]) -> None:
  2245. """Execute the tag command.
  2246. Args:
  2247. args: Command line arguments
  2248. """
  2249. parser = argparse.ArgumentParser()
  2250. parser.add_argument(
  2251. "-a",
  2252. "--annotated",
  2253. help="Create an annotated tag.",
  2254. action="store_true",
  2255. )
  2256. parser.add_argument(
  2257. "-s", "--sign", help="Sign the annotated tag.", action="store_true"
  2258. )
  2259. parser.add_argument("tag_name", help="Name of the tag to create")
  2260. parsed_args = parser.parse_args(args)
  2261. porcelain.tag_create(
  2262. ".",
  2263. parsed_args.tag_name,
  2264. annotated=parsed_args.annotated,
  2265. sign=parsed_args.sign,
  2266. )
  2267. class cmd_verify_commit(Command):
  2268. """Check the GPG signature of commits."""
  2269. def run(self, args: Sequence[str]) -> int | None:
  2270. """Execute the verify-commit command.
  2271. Args:
  2272. args: Command line arguments
  2273. Returns:
  2274. Exit code (1 on error, None on success)
  2275. """
  2276. parser = argparse.ArgumentParser()
  2277. parser.add_argument(
  2278. "-v",
  2279. "--verbose",
  2280. help="Print the contents of the commit object before validating it.",
  2281. action="store_true",
  2282. )
  2283. parser.add_argument(
  2284. "--raw",
  2285. help="Print the raw gpg status output to standard error.",
  2286. action="store_true",
  2287. )
  2288. parser.add_argument(
  2289. "commits",
  2290. nargs="*",
  2291. default=["HEAD"],
  2292. help="Commits to verify (defaults to HEAD)",
  2293. )
  2294. parsed_args = parser.parse_args(args)
  2295. exit_code = None
  2296. for commit in parsed_args.commits:
  2297. try:
  2298. if parsed_args.verbose:
  2299. # Show commit contents before verification
  2300. porcelain.show(
  2301. ".",
  2302. objects=[commit],
  2303. outstream=sys.stdout,
  2304. )
  2305. porcelain.verify_commit(".", commit)
  2306. if not parsed_args.raw:
  2307. print(f"gpg: Good signature from commit '{commit}'")
  2308. except Exception as e:
  2309. if not parsed_args.raw:
  2310. print(f"error: {commit}: {e}", file=sys.stderr)
  2311. else:
  2312. # In raw mode, let the exception propagate
  2313. raise
  2314. exit_code = 1
  2315. return exit_code
  2316. class cmd_verify_tag(Command):
  2317. """Check the GPG signature of tags."""
  2318. def run(self, args: Sequence[str]) -> int | None:
  2319. """Execute the verify-tag command.
  2320. Args:
  2321. args: Command line arguments
  2322. Returns:
  2323. Exit code (1 on error, None on success)
  2324. """
  2325. parser = argparse.ArgumentParser()
  2326. parser.add_argument(
  2327. "-v",
  2328. "--verbose",
  2329. help="Print the contents of the tag object before validating it.",
  2330. action="store_true",
  2331. )
  2332. parser.add_argument(
  2333. "--raw",
  2334. help="Print the raw gpg status output to standard error.",
  2335. action="store_true",
  2336. )
  2337. parser.add_argument("tags", nargs="+", help="Tags to verify")
  2338. parsed_args = parser.parse_args(args)
  2339. exit_code = None
  2340. for tag in parsed_args.tags:
  2341. try:
  2342. if parsed_args.verbose:
  2343. # Show tag contents before verification
  2344. porcelain.show(
  2345. ".",
  2346. objects=[tag],
  2347. outstream=sys.stdout,
  2348. )
  2349. porcelain.verify_tag(".", tag)
  2350. if not parsed_args.raw:
  2351. print(f"gpg: Good signature from tag '{tag}'")
  2352. except Exception as e:
  2353. if not parsed_args.raw:
  2354. print(f"error: {tag}: {e}", file=sys.stderr)
  2355. else:
  2356. # In raw mode, let the exception propagate
  2357. raise
  2358. exit_code = 1
  2359. return exit_code
  2360. class cmd_repack(Command):
  2361. """Pack unpacked objects in a repository."""
  2362. def run(self, args: Sequence[str]) -> None:
  2363. """Execute the repack command.
  2364. Args:
  2365. args: Command line arguments
  2366. """
  2367. parser = argparse.ArgumentParser()
  2368. parser.parse_args(args)
  2369. porcelain.repack(".")
  2370. class cmd_reflog(Command):
  2371. """Manage reflog information."""
  2372. def run(self, args: Sequence[str]) -> None:
  2373. """Execute the reflog command.
  2374. Args:
  2375. args: Command line arguments
  2376. """
  2377. parser = argparse.ArgumentParser(prog="dulwich reflog")
  2378. subparsers = parser.add_subparsers(dest="subcommand", help="Subcommand")
  2379. # Show subcommand (default when no subcommand is specified)
  2380. show_parser = subparsers.add_parser(
  2381. "show", help="Show reflog entries (default)", add_help=False
  2382. )
  2383. show_parser.add_argument(
  2384. "ref", nargs="?", default="HEAD", help="Reference to show reflog for"
  2385. )
  2386. show_parser.add_argument(
  2387. "--all", action="store_true", help="Show reflogs for all refs"
  2388. )
  2389. # Expire subcommand
  2390. expire_parser = subparsers.add_parser("expire", help="Expire reflog entries")
  2391. expire_parser.add_argument(
  2392. "ref", nargs="?", help="Reference to expire reflog for"
  2393. )
  2394. expire_parser.add_argument(
  2395. "--all", action="store_true", help="Expire reflogs for all refs"
  2396. )
  2397. expire_parser.add_argument(
  2398. "--expire",
  2399. type=str,
  2400. help="Expire entries older than time (e.g., '90 days ago', 'all', 'never')",
  2401. )
  2402. expire_parser.add_argument(
  2403. "--expire-unreachable",
  2404. type=str,
  2405. help="Expire unreachable entries older than time",
  2406. )
  2407. expire_parser.add_argument(
  2408. "--dry-run", "-n", action="store_true", help="Show what would be expired"
  2409. )
  2410. # Delete subcommand
  2411. delete_parser = subparsers.add_parser(
  2412. "delete", help="Delete specific reflog entry"
  2413. )
  2414. delete_parser.add_argument(
  2415. "refspec", help="Reference specification (e.g., HEAD@{1})"
  2416. )
  2417. delete_parser.add_argument(
  2418. "--rewrite",
  2419. action="store_true",
  2420. help="Rewrite subsequent entries to maintain consistency",
  2421. )
  2422. # If no arguments or first arg is not a subcommand, treat as show
  2423. if not args or (args[0] not in ["show", "expire", "delete"]):
  2424. # Parse as show command
  2425. parsed_args = parser.parse_args(["show", *list(args)])
  2426. else:
  2427. parsed_args = parser.parse_args(args)
  2428. if parsed_args.subcommand == "expire":
  2429. self._run_expire(parsed_args)
  2430. elif parsed_args.subcommand == "delete":
  2431. self._run_delete(parsed_args)
  2432. else: # show or default
  2433. self._run_show(parsed_args)
  2434. def _run_show(self, parsed_args: argparse.Namespace) -> None:
  2435. """Show reflog entries."""
  2436. with Repo(".") as repo:
  2437. config = repo.get_config_stack()
  2438. with get_pager(config=config, cmd_name="reflog") as outstream:
  2439. if parsed_args.all:
  2440. # Show reflogs for all refs
  2441. for ref_bytes, entry in porcelain.reflog(repo, all=True):
  2442. ref_str = ref_bytes.decode("utf-8", "replace")
  2443. short_new = entry.new_sha[:8].decode("ascii")
  2444. outstream.write(
  2445. f"{short_new} {ref_str}: {entry.message.decode('utf-8', 'replace')}\n"
  2446. )
  2447. else:
  2448. ref = (
  2449. parsed_args.ref.encode("utf-8")
  2450. if isinstance(parsed_args.ref, str)
  2451. else parsed_args.ref
  2452. )
  2453. for i, entry in enumerate(porcelain.reflog(repo, ref)):
  2454. # Format similar to git reflog
  2455. from dulwich.reflog import Entry
  2456. assert isinstance(entry, Entry)
  2457. short_new = entry.new_sha[:8].decode("ascii")
  2458. message = (
  2459. entry.message.decode("utf-8", "replace")
  2460. if entry.message
  2461. else ""
  2462. )
  2463. outstream.write(
  2464. f"{short_new} {ref.decode('utf-8', 'replace')}@{{{i}}}: {message}\n"
  2465. )
  2466. def _run_expire(self, parsed_args: argparse.Namespace) -> None:
  2467. """Expire reflog entries."""
  2468. # Parse time specifications
  2469. expire_time = None
  2470. expire_unreachable_time = None
  2471. if parsed_args.expire:
  2472. expire_time = parse_time_to_timestamp(parsed_args.expire)
  2473. if parsed_args.expire_unreachable:
  2474. expire_unreachable_time = parse_time_to_timestamp(
  2475. parsed_args.expire_unreachable
  2476. )
  2477. # Execute expire
  2478. result = porcelain.reflog_expire(
  2479. repo=".",
  2480. ref=parsed_args.ref,
  2481. all=parsed_args.all,
  2482. expire_time=expire_time,
  2483. expire_unreachable_time=expire_unreachable_time,
  2484. dry_run=parsed_args.dry_run,
  2485. )
  2486. # Print results
  2487. for ref_name, count in result.items():
  2488. ref_str = ref_name.decode("utf-8", "replace")
  2489. if parsed_args.dry_run:
  2490. print(f"Would expire {count} entries from {ref_str}")
  2491. else:
  2492. print(f"Expired {count} entries from {ref_str}")
  2493. def _run_delete(self, parsed_args: argparse.Namespace) -> None:
  2494. """Delete a specific reflog entry."""
  2495. from dulwich.reflog import parse_reflog_spec
  2496. # Parse refspec (e.g., "HEAD@{1}" or "refs/heads/master@{2}")
  2497. ref, index = parse_reflog_spec(parsed_args.refspec)
  2498. # Execute delete
  2499. porcelain.reflog_delete(
  2500. repo=".",
  2501. ref=ref,
  2502. index=index,
  2503. rewrite=parsed_args.rewrite,
  2504. )
  2505. print(f"Deleted entry {ref.decode('utf-8', 'replace')}@{{{index}}}")
  2506. class cmd_reset(Command):
  2507. """Reset current HEAD to the specified state."""
  2508. def run(self, args: Sequence[str]) -> None:
  2509. """Execute the reset command.
  2510. Args:
  2511. args: Command line arguments
  2512. """
  2513. parser = argparse.ArgumentParser()
  2514. mode_group = parser.add_mutually_exclusive_group()
  2515. mode_group.add_argument(
  2516. "--hard", action="store_true", help="Reset working tree and index"
  2517. )
  2518. mode_group.add_argument("--soft", action="store_true", help="Reset only HEAD")
  2519. mode_group.add_argument(
  2520. "--mixed", action="store_true", help="Reset HEAD and index"
  2521. )
  2522. parser.add_argument("treeish", nargs="?", help="Commit/tree to reset to")
  2523. parsed_args = parser.parse_args(args)
  2524. if parsed_args.hard:
  2525. mode = "hard"
  2526. elif parsed_args.soft:
  2527. mode = "soft"
  2528. elif parsed_args.mixed:
  2529. mode = "mixed"
  2530. else:
  2531. # Default to mixed behavior
  2532. mode = "mixed"
  2533. # Use the porcelain.reset function for all modes
  2534. porcelain.reset(".", mode=mode, treeish=parsed_args.treeish)
  2535. class cmd_revert(Command):
  2536. """Revert some existing commits."""
  2537. def run(self, args: Sequence[str]) -> None:
  2538. """Execute the revert command.
  2539. Args:
  2540. args: Command line arguments
  2541. """
  2542. parser = argparse.ArgumentParser()
  2543. parser.add_argument(
  2544. "--no-commit",
  2545. "-n",
  2546. action="store_true",
  2547. help="Apply changes but don't create a commit",
  2548. )
  2549. parser.add_argument("-m", "--message", help="Custom commit message")
  2550. parser.add_argument("commits", nargs="+", help="Commits to revert")
  2551. parsed_args = parser.parse_args(args)
  2552. result = porcelain.revert(
  2553. ".",
  2554. commits=parsed_args.commits,
  2555. no_commit=parsed_args.no_commit,
  2556. message=parsed_args.message,
  2557. )
  2558. if result and not parsed_args.no_commit:
  2559. logger.info("[%s] Revert completed", result.decode("ascii")[:7])
  2560. class cmd_daemon(Command):
  2561. """Run a simple Git protocol server."""
  2562. def run(self, args: Sequence[str]) -> None:
  2563. """Execute the daemon command.
  2564. Args:
  2565. args: Command line arguments
  2566. """
  2567. from dulwich import log_utils
  2568. from .protocol import TCP_GIT_PORT
  2569. parser = argparse.ArgumentParser()
  2570. parser.add_argument(
  2571. "-l",
  2572. "--listen_address",
  2573. default="localhost",
  2574. help="Binding IP address.",
  2575. )
  2576. parser.add_argument(
  2577. "-p",
  2578. "--port",
  2579. type=int,
  2580. default=TCP_GIT_PORT,
  2581. help="Binding TCP port.",
  2582. )
  2583. parser.add_argument(
  2584. "gitdir", nargs="?", default=".", help="Git directory to serve"
  2585. )
  2586. parsed_args = parser.parse_args(args)
  2587. log_utils.default_logging_config()
  2588. porcelain.daemon(
  2589. parsed_args.gitdir,
  2590. address=parsed_args.listen_address,
  2591. port=parsed_args.port,
  2592. )
  2593. class cmd_web_daemon(Command):
  2594. """Run a simple HTTP server for Git repositories."""
  2595. def run(self, args: Sequence[str]) -> None:
  2596. """Execute the web-daemon command.
  2597. Args:
  2598. args: Command line arguments
  2599. """
  2600. from dulwich import log_utils
  2601. parser = argparse.ArgumentParser()
  2602. parser.add_argument(
  2603. "-l",
  2604. "--listen_address",
  2605. default="",
  2606. help="Binding IP address.",
  2607. )
  2608. parser.add_argument(
  2609. "-p",
  2610. "--port",
  2611. type=int,
  2612. default=8000,
  2613. help="Binding TCP port.",
  2614. )
  2615. parser.add_argument(
  2616. "gitdir", nargs="?", default=".", help="Git directory to serve"
  2617. )
  2618. parsed_args = parser.parse_args(args)
  2619. log_utils.default_logging_config()
  2620. porcelain.web_daemon(
  2621. parsed_args.gitdir,
  2622. address=parsed_args.listen_address,
  2623. port=parsed_args.port,
  2624. )
  2625. class cmd_write_tree(Command):
  2626. """Create a tree object from the current index."""
  2627. def run(self, args: Sequence[str]) -> None:
  2628. """Execute the write-tree command.
  2629. Args:
  2630. args: Command line arguments
  2631. """
  2632. parser = argparse.ArgumentParser()
  2633. parser.parse_args(args)
  2634. sys.stdout.write("{}\n".format(porcelain.write_tree(".").decode()))
  2635. class cmd_receive_pack(Command):
  2636. """Receive what is pushed into the repository."""
  2637. def run(self, args: Sequence[str]) -> None:
  2638. """Execute the receive-pack command.
  2639. Args:
  2640. args: Command line arguments
  2641. """
  2642. parser = argparse.ArgumentParser()
  2643. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  2644. parsed_args = parser.parse_args(args)
  2645. porcelain.receive_pack(parsed_args.gitdir)
  2646. class cmd_upload_pack(Command):
  2647. """Send objects packed back to git-fetch-pack."""
  2648. def run(self, args: Sequence[str]) -> None:
  2649. """Execute the upload-pack command.
  2650. Args:
  2651. args: Command line arguments
  2652. """
  2653. parser = argparse.ArgumentParser()
  2654. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  2655. parsed_args = parser.parse_args(args)
  2656. porcelain.upload_pack(parsed_args.gitdir)
  2657. class cmd_shortlog(Command):
  2658. """Show a shortlog of commits by author."""
  2659. def run(self, args: Sequence[str]) -> None:
  2660. """Execute the shortlog command with the given CLI arguments.
  2661. Args:
  2662. args: List of command line arguments.
  2663. """
  2664. parser = argparse.ArgumentParser()
  2665. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  2666. parser.add_argument("--summary", action="store_true", help="Show summary only")
  2667. parser.add_argument(
  2668. "--sort", action="store_true", help="Sort authors by commit count"
  2669. )
  2670. parsed_args = parser.parse_args(args)
  2671. shortlog_items: list[dict[str, str]] = porcelain.shortlog(
  2672. repo=parsed_args.gitdir,
  2673. summary_only=parsed_args.summary,
  2674. sort_by_commits=parsed_args.sort,
  2675. )
  2676. for item in shortlog_items:
  2677. author: str = item["author"]
  2678. messages: str = item["messages"]
  2679. if parsed_args.summary:
  2680. count = len(messages.splitlines())
  2681. sys.stdout.write(f"{count}\t{author}\n")
  2682. else:
  2683. sys.stdout.write(f"{author} ({len(messages.splitlines())}):\n")
  2684. for msg in messages.splitlines():
  2685. sys.stdout.write(f" {msg}\n")
  2686. sys.stdout.write("\n")
  2687. class cmd_status(Command):
  2688. """Show the working tree status."""
  2689. def run(self, args: Sequence[str]) -> None:
  2690. """Execute the status command.
  2691. Args:
  2692. args: Command line arguments
  2693. """
  2694. parser = argparse.ArgumentParser()
  2695. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  2696. parser.add_argument(
  2697. "--column",
  2698. action="store_true",
  2699. help="Display untracked files in columns",
  2700. )
  2701. parsed_args = parser.parse_args(args)
  2702. status = porcelain.status(parsed_args.gitdir)
  2703. if any(names for (kind, names) in status.staged.items()):
  2704. sys.stdout.write("Changes to be committed:\n\n")
  2705. for kind, names in status.staged.items():
  2706. for name in names:
  2707. sys.stdout.write(
  2708. f"\t{kind}: {name.decode(sys.getfilesystemencoding())}\n"
  2709. )
  2710. sys.stdout.write("\n")
  2711. if status.unstaged:
  2712. sys.stdout.write("Changes not staged for commit:\n\n")
  2713. for name in status.unstaged:
  2714. sys.stdout.write(f"\t{name.decode(sys.getfilesystemencoding())}\n")
  2715. sys.stdout.write("\n")
  2716. if status.untracked:
  2717. sys.stdout.write("Untracked files:\n\n")
  2718. if parsed_args.column:
  2719. # Format untracked files in columns
  2720. untracked_names = [name for name in status.untracked]
  2721. output = format_columns(untracked_names, mode="column", indent="\t")
  2722. sys.stdout.write(output)
  2723. else:
  2724. for name in status.untracked:
  2725. sys.stdout.write(f"\t{name}\n")
  2726. sys.stdout.write("\n")
  2727. class cmd_ls_remote(Command):
  2728. """List references in a remote repository."""
  2729. def run(self, args: Sequence[str]) -> None:
  2730. """Execute the ls-remote command.
  2731. Args:
  2732. args: Command line arguments
  2733. """
  2734. parser = argparse.ArgumentParser()
  2735. parser.add_argument(
  2736. "--symref", action="store_true", help="Show symbolic references"
  2737. )
  2738. parser.add_argument("url", help="Remote URL to list references from")
  2739. parsed_args = parser.parse_args(args)
  2740. result = porcelain.ls_remote(parsed_args.url)
  2741. if parsed_args.symref:
  2742. # Show symrefs first, like git does
  2743. for ref, target in sorted(result.symrefs.items()):
  2744. if target:
  2745. sys.stdout.write(f"ref: {target.decode()}\t{ref.decode()}\n")
  2746. # Show regular refs
  2747. for ref in sorted(result.refs):
  2748. sha = result.refs[ref]
  2749. if sha is not None:
  2750. sys.stdout.write(f"{sha.decode()}\t{ref.decode()}\n")
  2751. class cmd_ls_tree(Command):
  2752. """List the contents of a tree object."""
  2753. def run(self, args: Sequence[str]) -> None:
  2754. """Execute the ls-tree command.
  2755. Args:
  2756. args: Command line arguments
  2757. """
  2758. parser = argparse.ArgumentParser()
  2759. parser.add_argument(
  2760. "-r",
  2761. "--recursive",
  2762. action="store_true",
  2763. help="Recursively list tree contents.",
  2764. )
  2765. parser.add_argument(
  2766. "--name-only", action="store_true", help="Only display name."
  2767. )
  2768. parser.add_argument("treeish", nargs="?", help="Tree-ish to list")
  2769. parsed_args = parser.parse_args(args)
  2770. with Repo(".") as repo:
  2771. config = repo.get_config_stack()
  2772. with get_pager(config=config, cmd_name="ls-tree") as outstream:
  2773. porcelain.ls_tree(
  2774. repo,
  2775. parsed_args.treeish,
  2776. outstream=outstream,
  2777. recursive=parsed_args.recursive,
  2778. name_only=parsed_args.name_only,
  2779. )
  2780. class cmd_pack_objects(Command):
  2781. """Create a packed archive of objects."""
  2782. def run(self, args: Sequence[str]) -> None:
  2783. """Execute the pack-objects command.
  2784. Args:
  2785. args: Command line arguments
  2786. """
  2787. parser = argparse.ArgumentParser()
  2788. parser.add_argument(
  2789. "--stdout", action="store_true", help="Write pack to stdout"
  2790. )
  2791. parser.add_argument("--deltify", action="store_true", help="Create deltas")
  2792. parser.add_argument(
  2793. "--no-reuse-deltas", action="store_true", help="Don't reuse existing deltas"
  2794. )
  2795. parser.add_argument("basename", nargs="?", help="Base name for pack files")
  2796. parsed_args = parser.parse_args(args)
  2797. if not parsed_args.stdout and not parsed_args.basename:
  2798. parser.error("basename required when not using --stdout")
  2799. object_ids = [line.strip().encode() for line in sys.stdin.readlines()]
  2800. deltify = parsed_args.deltify
  2801. reuse_deltas = not parsed_args.no_reuse_deltas
  2802. if parsed_args.stdout:
  2803. packf = getattr(sys.stdout, "buffer", sys.stdout)
  2804. assert isinstance(packf, BinaryIO)
  2805. idxf = None
  2806. close = []
  2807. else:
  2808. packf = open(parsed_args.basename + ".pack", "wb")
  2809. idxf = open(parsed_args.basename + ".idx", "wb")
  2810. close = [packf, idxf]
  2811. porcelain.pack_objects(
  2812. ".", object_ids, packf, idxf, deltify=deltify, reuse_deltas=reuse_deltas
  2813. )
  2814. for f in close:
  2815. f.close()
  2816. class cmd_unpack_objects(Command):
  2817. """Unpack objects from a packed archive."""
  2818. def run(self, args: Sequence[str]) -> None:
  2819. """Execute the unpack-objects command.
  2820. Args:
  2821. args: Command line arguments
  2822. """
  2823. parser = argparse.ArgumentParser()
  2824. parser.add_argument("pack_file", help="Pack file to unpack")
  2825. parsed_args = parser.parse_args(args)
  2826. count = porcelain.unpack_objects(parsed_args.pack_file)
  2827. logger.info("Unpacked %d objects", count)
  2828. class cmd_prune(Command):
  2829. """Prune all unreachable objects from the object database."""
  2830. def run(self, args: Sequence[str]) -> int | None:
  2831. """Execute the prune command.
  2832. Args:
  2833. args: Command line arguments
  2834. """
  2835. import datetime
  2836. import time
  2837. from dulwich.object_store import DEFAULT_TEMPFILE_GRACE_PERIOD
  2838. parser = argparse.ArgumentParser(
  2839. description="Remove temporary pack files left behind by interrupted operations"
  2840. )
  2841. parser.add_argument(
  2842. "--expire",
  2843. nargs="?",
  2844. const="2.weeks.ago",
  2845. help="Only prune files older than the specified date (default: 2.weeks.ago)",
  2846. )
  2847. parser.add_argument(
  2848. "--dry-run",
  2849. "-n",
  2850. action="store_true",
  2851. help="Only report what would be removed",
  2852. )
  2853. parser.add_argument(
  2854. "--verbose",
  2855. "-v",
  2856. action="store_true",
  2857. help="Report all actions",
  2858. )
  2859. parsed_args = parser.parse_args(args)
  2860. # Parse expire grace period
  2861. grace_period = DEFAULT_TEMPFILE_GRACE_PERIOD
  2862. if parsed_args.expire:
  2863. from .approxidate import parse_relative_time
  2864. try:
  2865. grace_period = parse_relative_time(parsed_args.expire)
  2866. except ValueError:
  2867. # Try to parse as absolute date
  2868. try:
  2869. date = datetime.datetime.strptime(parsed_args.expire, "%Y-%m-%d")
  2870. grace_period = int(time.time() - date.timestamp())
  2871. except ValueError:
  2872. logger.error("Invalid expire date: %s", parsed_args.expire)
  2873. return 1
  2874. # Progress callback
  2875. def progress(msg: str) -> None:
  2876. if parsed_args.verbose:
  2877. logger.info("%s", msg)
  2878. try:
  2879. porcelain.prune(
  2880. ".",
  2881. grace_period=grace_period,
  2882. dry_run=parsed_args.dry_run,
  2883. progress=progress if parsed_args.verbose else None,
  2884. )
  2885. return None
  2886. except porcelain.Error as e:
  2887. logger.error("%s", e)
  2888. return 1
  2889. class cmd_pull(Command):
  2890. """Fetch from and integrate with another repository or a local branch."""
  2891. def run(self, args: Sequence[str]) -> None:
  2892. """Execute the pull command.
  2893. Args:
  2894. args: Command line arguments
  2895. """
  2896. parser = argparse.ArgumentParser()
  2897. parser.add_argument("from_location", type=str)
  2898. parser.add_argument("refspec", type=str, nargs="*")
  2899. parser.add_argument("--filter", type=str, nargs=1)
  2900. parser.add_argument("--protocol", type=int)
  2901. parsed_args = parser.parse_args(args)
  2902. porcelain.pull(
  2903. ".",
  2904. remote_location=parsed_args.from_location or None,
  2905. refspecs=parsed_args.refspec or None,
  2906. filter_spec=parsed_args.filter,
  2907. protocol_version=parsed_args.protocol or None,
  2908. )
  2909. class cmd_push(Command):
  2910. """Update remote refs along with associated objects."""
  2911. def run(self, argv: Sequence[str]) -> int | None:
  2912. """Execute the push command.
  2913. Args:
  2914. argv: Command line arguments
  2915. """
  2916. parser = argparse.ArgumentParser()
  2917. parser.add_argument("-f", "--force", action="store_true", help="Force")
  2918. parser.add_argument("to_location", type=str)
  2919. parser.add_argument("refspec", type=str, nargs="*")
  2920. args = parser.parse_args(argv)
  2921. try:
  2922. porcelain.push(
  2923. ".", args.to_location, args.refspec or None, force=args.force
  2924. )
  2925. except porcelain.DivergedBranches:
  2926. sys.stderr.write("Diverged branches; specify --force to override")
  2927. return 1
  2928. return None
  2929. class cmd_remote_add(Command):
  2930. """Add a remote repository."""
  2931. def run(self, args: Sequence[str]) -> None:
  2932. """Execute the remote-add command.
  2933. Args:
  2934. args: Command line arguments
  2935. """
  2936. parser = argparse.ArgumentParser()
  2937. parser.add_argument("name", help="Name of the remote")
  2938. parser.add_argument("url", help="URL of the remote")
  2939. parsed_args = parser.parse_args(args)
  2940. porcelain.remote_add(".", parsed_args.name, parsed_args.url)
  2941. class SuperCommand(Command):
  2942. """Base class for commands that have subcommands."""
  2943. subcommands: ClassVar[dict[str, type[Command]]] = {}
  2944. default_command: ClassVar[type[Command] | None] = None
  2945. def run(self, args: Sequence[str]) -> int | None:
  2946. """Execute the subcommand command.
  2947. Args:
  2948. args: Command line arguments
  2949. """
  2950. if not args:
  2951. if self.default_command:
  2952. return self.default_command().run(args)
  2953. else:
  2954. logger.info(
  2955. "Supported subcommands: %s", ", ".join(self.subcommands.keys())
  2956. )
  2957. return False
  2958. cmd = args[0]
  2959. try:
  2960. cmd_kls = self.subcommands[cmd]
  2961. except KeyError:
  2962. logger.error("No such subcommand: %s", args[0])
  2963. sys.exit(1)
  2964. return cmd_kls().run(args[1:])
  2965. class cmd_remote(SuperCommand):
  2966. """Manage set of tracked repositories."""
  2967. subcommands: ClassVar[dict[str, type[Command]]] = {
  2968. "add": cmd_remote_add,
  2969. }
  2970. class cmd_submodule_list(Command):
  2971. """List submodules."""
  2972. def run(self, argv: Sequence[str]) -> None:
  2973. """Execute the submodule-list command.
  2974. Args:
  2975. argv: Command line arguments
  2976. """
  2977. parser = argparse.ArgumentParser()
  2978. parser.parse_args(argv)
  2979. for path, sha in porcelain.submodule_list("."):
  2980. sys.stdout.write(f" {sha} {path}\n")
  2981. class cmd_submodule_init(Command):
  2982. """Initialize submodules."""
  2983. def run(self, argv: Sequence[str]) -> None:
  2984. """Execute the submodule-init command.
  2985. Args:
  2986. argv: Command line arguments
  2987. """
  2988. parser = argparse.ArgumentParser()
  2989. parser.parse_args(argv)
  2990. porcelain.submodule_init(".")
  2991. class cmd_submodule_add(Command):
  2992. """Add a submodule."""
  2993. def run(self, argv: Sequence[str]) -> None:
  2994. """Execute the submodule-add command.
  2995. Args:
  2996. argv: Command line arguments
  2997. """
  2998. parser = argparse.ArgumentParser()
  2999. parser.add_argument("url", help="URL of repository to add as submodule")
  3000. parser.add_argument("path", nargs="?", help="Path where submodule should live")
  3001. parser.add_argument("--name", help="Name for the submodule")
  3002. args = parser.parse_args(argv)
  3003. porcelain.submodule_add(".", args.url, args.path, args.name)
  3004. class cmd_submodule_update(Command):
  3005. """Update submodules."""
  3006. def run(self, argv: Sequence[str]) -> None:
  3007. """Execute the submodule-update command.
  3008. Args:
  3009. argv: Command line arguments
  3010. """
  3011. parser = argparse.ArgumentParser()
  3012. parser.add_argument(
  3013. "--init", action="store_true", help="Initialize submodules first"
  3014. )
  3015. parser.add_argument(
  3016. "--force",
  3017. action="store_true",
  3018. help="Force update even if local changes exist",
  3019. )
  3020. parser.add_argument(
  3021. "--recursive",
  3022. action="store_true",
  3023. help="Recursively update nested submodules",
  3024. )
  3025. parser.add_argument(
  3026. "paths", nargs="*", help="Specific submodule paths to update"
  3027. )
  3028. args = parser.parse_args(argv)
  3029. paths = args.paths if args.paths else None
  3030. porcelain.submodule_update(
  3031. ".", paths=paths, init=args.init, force=args.force, recursive=args.recursive
  3032. )
  3033. class cmd_submodule(SuperCommand):
  3034. """Initialize, update or inspect submodules."""
  3035. subcommands: ClassVar[dict[str, type[Command]]] = {
  3036. "add": cmd_submodule_add,
  3037. "init": cmd_submodule_init,
  3038. "list": cmd_submodule_list,
  3039. "update": cmd_submodule_update,
  3040. }
  3041. default_command = cmd_submodule_list
  3042. class cmd_check_ignore(Command):
  3043. """Check whether files are excluded by gitignore."""
  3044. def run(self, args: Sequence[str]) -> int:
  3045. """Execute the check-ignore command.
  3046. Args:
  3047. args: Command line arguments
  3048. """
  3049. parser = argparse.ArgumentParser()
  3050. parser.add_argument("paths", nargs="+", help="Paths to check")
  3051. parsed_args = parser.parse_args(args)
  3052. ret = 1
  3053. for path in porcelain.check_ignore(".", parsed_args.paths):
  3054. logger.info(path)
  3055. ret = 0
  3056. return ret
  3057. class cmd_check_mailmap(Command):
  3058. """Show canonical names and email addresses of contacts."""
  3059. def run(self, args: Sequence[str]) -> None:
  3060. """Execute the check-mailmap command.
  3061. Args:
  3062. args: Command line arguments
  3063. """
  3064. parser = argparse.ArgumentParser()
  3065. parser.add_argument("identities", nargs="+", help="Identities to check")
  3066. parsed_args = parser.parse_args(args)
  3067. for identity in parsed_args.identities:
  3068. canonical_identity = porcelain.check_mailmap(".", identity)
  3069. logger.info(canonical_identity)
  3070. class cmd_branch(Command):
  3071. """List, create, or delete branches."""
  3072. def run(self, args: Sequence[str]) -> int | None:
  3073. """Execute the branch command.
  3074. Args:
  3075. args: Command line arguments
  3076. """
  3077. parser = argparse.ArgumentParser()
  3078. parser.add_argument(
  3079. "branch",
  3080. type=str,
  3081. nargs="?",
  3082. help="Name of the branch",
  3083. )
  3084. parser.add_argument(
  3085. "-d",
  3086. "--delete",
  3087. action="store_true",
  3088. help="Delete branch",
  3089. )
  3090. parser.add_argument("--all", action="store_true", help="List all branches")
  3091. parser.add_argument(
  3092. "--merged", action="store_true", help="List merged into current branch"
  3093. )
  3094. parser.add_argument(
  3095. "--no-merged",
  3096. action="store_true",
  3097. help="List branches not merged into current branch",
  3098. )
  3099. parser.add_argument(
  3100. "--remotes", action="store_true", help="List remotes branches"
  3101. )
  3102. parser.add_argument(
  3103. "--contains",
  3104. nargs="?",
  3105. const="HEAD",
  3106. help="List branches that contain a specific commit",
  3107. )
  3108. parser.add_argument(
  3109. "--column", action="store_true", help="Display branch list in columns"
  3110. )
  3111. parser.add_argument(
  3112. "--list",
  3113. nargs="?",
  3114. const=None,
  3115. help="List branches matching a pattern",
  3116. )
  3117. parsed_args = parser.parse_args(args)
  3118. def print_branches(
  3119. branches: Iterator[bytes] | Sequence[bytes], use_columns: bool = False
  3120. ) -> None:
  3121. if use_columns:
  3122. branch_names = [branch.decode() for branch in branches]
  3123. output = format_columns(branch_names, mode="column")
  3124. sys.stdout.write(output)
  3125. else:
  3126. for branch in branches:
  3127. sys.stdout.write(f"{branch.decode()}\n")
  3128. branches: Iterator[bytes] | list[bytes] | None = None
  3129. try:
  3130. if parsed_args.all:
  3131. branches = porcelain.branch_list(".") + porcelain.branch_remotes_list(
  3132. "."
  3133. )
  3134. elif parsed_args.remotes:
  3135. branches = porcelain.branch_remotes_list(".")
  3136. elif parsed_args.merged:
  3137. branches = porcelain.merged_branches(".")
  3138. elif parsed_args.no_merged:
  3139. branches = porcelain.no_merged_branches(".")
  3140. elif parsed_args.contains:
  3141. try:
  3142. branches = list(
  3143. porcelain.branches_containing(".", commit=parsed_args.contains)
  3144. )
  3145. except KeyError as e:
  3146. sys.stderr.write(
  3147. f"error: object name {e.args[0].decode()} not found\n"
  3148. )
  3149. return 1
  3150. except porcelain.Error as e:
  3151. sys.stderr.write(f"{e}")
  3152. return 1
  3153. pattern = parsed_args.list
  3154. if pattern is not None and branches:
  3155. branches = porcelain.filter_branches_by_pattern(branches, pattern)
  3156. if branches is not None:
  3157. print_branches(branches, parsed_args.column)
  3158. return 0
  3159. if not parsed_args.branch:
  3160. logger.error("Usage: dulwich branch [-d] BRANCH_NAME")
  3161. return 1
  3162. if parsed_args.delete:
  3163. porcelain.branch_delete(".", name=parsed_args.branch)
  3164. else:
  3165. try:
  3166. porcelain.branch_create(".", name=parsed_args.branch)
  3167. except porcelain.Error as e:
  3168. sys.stderr.write(f"{e}")
  3169. return 1
  3170. return 0
  3171. class cmd_checkout(Command):
  3172. """Switch branches or restore working tree files."""
  3173. def run(self, args: Sequence[str]) -> int | None:
  3174. """Execute the checkout command.
  3175. Args:
  3176. args: Command line arguments
  3177. """
  3178. parser = argparse.ArgumentParser()
  3179. parser.add_argument(
  3180. "target",
  3181. type=str,
  3182. help="Name of the branch, tag, or commit to checkout",
  3183. )
  3184. parser.add_argument(
  3185. "-f",
  3186. "--force",
  3187. action="store_true",
  3188. help="Force checkout",
  3189. )
  3190. parser.add_argument(
  3191. "-b",
  3192. "--new-branch",
  3193. type=str,
  3194. help="Create a new branch at the target and switch to it",
  3195. )
  3196. parsed_args = parser.parse_args(args)
  3197. if not parsed_args.target:
  3198. logger.error("Usage: dulwich checkout TARGET [--force] [-b NEW_BRANCH]")
  3199. return 1
  3200. try:
  3201. porcelain.checkout(
  3202. ".",
  3203. target=parsed_args.target,
  3204. force=parsed_args.force,
  3205. new_branch=parsed_args.new_branch,
  3206. )
  3207. except porcelain.CheckoutError as e:
  3208. sys.stderr.write(f"{e}\n")
  3209. return 1
  3210. return 0
  3211. class cmd_stash_list(Command):
  3212. """List stash entries."""
  3213. def run(self, args: Sequence[str]) -> None:
  3214. """Execute the stash-list command.
  3215. Args:
  3216. args: Command line arguments
  3217. """
  3218. parser = argparse.ArgumentParser()
  3219. parser.parse_args(args)
  3220. from .repo import Repo
  3221. from .stash import Stash
  3222. with Repo(".") as r:
  3223. stash = Stash.from_repo(r)
  3224. for i, entry in enumerate(stash.stashes()):
  3225. logger.info(
  3226. "stash@{%d}: %s",
  3227. i,
  3228. entry.message.decode("utf-8", "replace").rstrip("\n"),
  3229. )
  3230. class cmd_stash_push(Command):
  3231. """Save your local modifications to a new stash."""
  3232. def run(self, args: Sequence[str]) -> None:
  3233. """Execute the stash-push command.
  3234. Args:
  3235. args: Command line arguments
  3236. """
  3237. parser = argparse.ArgumentParser()
  3238. parser.parse_args(args)
  3239. porcelain.stash_push(".")
  3240. logger.info("Saved working directory and index state")
  3241. class cmd_stash_pop(Command):
  3242. """Apply a stash and remove it from the stash list."""
  3243. def run(self, args: Sequence[str]) -> None:
  3244. """Execute the stash-pop command.
  3245. Args:
  3246. args: Command line arguments
  3247. """
  3248. parser = argparse.ArgumentParser()
  3249. parser.parse_args(args)
  3250. porcelain.stash_pop(".")
  3251. logger.info("Restored working directory and index state")
  3252. class cmd_bisect(SuperCommand):
  3253. """Use binary search to find the commit that introduced a bug."""
  3254. subcommands: ClassVar[dict[str, type[Command]]] = {}
  3255. def run(self, args: Sequence[str]) -> int | None:
  3256. """Execute the bisect command.
  3257. Args:
  3258. args: Command line arguments
  3259. """
  3260. parser = argparse.ArgumentParser(prog="dulwich bisect")
  3261. subparsers = parser.add_subparsers(dest="subcommand", help="bisect subcommands")
  3262. # bisect start
  3263. start_parser = subparsers.add_parser("start", help="Start a new bisect session")
  3264. start_parser.add_argument("bad", nargs="?", help="Bad commit")
  3265. start_parser.add_argument("good", nargs="*", help="Good commit(s)")
  3266. start_parser.add_argument(
  3267. "--no-checkout",
  3268. action="store_true",
  3269. help="Don't checkout commits during bisect",
  3270. )
  3271. start_parser.add_argument(
  3272. "--term-bad", default="bad", help="Term to use for bad commits"
  3273. )
  3274. start_parser.add_argument(
  3275. "--term-good", default="good", help="Term to use for good commits"
  3276. )
  3277. start_parser.add_argument(
  3278. "--", dest="paths", nargs="*", help="Paths to limit bisect to"
  3279. )
  3280. # bisect bad
  3281. bad_parser = subparsers.add_parser("bad", help="Mark a commit as bad")
  3282. bad_parser.add_argument("rev", nargs="?", help="Commit to mark as bad")
  3283. # bisect good
  3284. good_parser = subparsers.add_parser("good", help="Mark a commit as good")
  3285. good_parser.add_argument("rev", nargs="?", help="Commit to mark as good")
  3286. # bisect skip
  3287. skip_parser = subparsers.add_parser("skip", help="Skip commits")
  3288. skip_parser.add_argument("revs", nargs="*", help="Commits to skip")
  3289. # bisect reset
  3290. reset_parser = subparsers.add_parser("reset", help="Reset bisect state")
  3291. reset_parser.add_argument("commit", nargs="?", help="Commit to reset to")
  3292. # bisect log
  3293. subparsers.add_parser("log", help="Show bisect log")
  3294. # bisect replay
  3295. replay_parser = subparsers.add_parser("replay", help="Replay bisect log")
  3296. replay_parser.add_argument("logfile", help="Log file to replay")
  3297. # bisect help
  3298. subparsers.add_parser("help", help="Show help")
  3299. parsed_args = parser.parse_args(args)
  3300. if not parsed_args.subcommand:
  3301. parser.print_help()
  3302. return 1
  3303. try:
  3304. if parsed_args.subcommand == "start":
  3305. next_sha = porcelain.bisect_start(
  3306. bad=parsed_args.bad,
  3307. good=parsed_args.good if parsed_args.good else None,
  3308. paths=parsed_args.paths,
  3309. no_checkout=parsed_args.no_checkout,
  3310. term_bad=parsed_args.term_bad,
  3311. term_good=parsed_args.term_good,
  3312. )
  3313. if next_sha:
  3314. logger.info(
  3315. "Bisecting: checking out '%s'", next_sha.decode("ascii")
  3316. )
  3317. elif parsed_args.subcommand == "bad":
  3318. next_sha = porcelain.bisect_bad(rev=parsed_args.rev)
  3319. if next_sha:
  3320. logger.info(
  3321. "Bisecting: checking out '%s'", next_sha.decode("ascii")
  3322. )
  3323. else:
  3324. # Bisect complete - find the first bad commit
  3325. with porcelain.open_repo_closing(".") as r:
  3326. bad_ref = os.path.join(r.controldir(), "refs", "bisect", "bad")
  3327. with open(bad_ref, "rb") as f:
  3328. bad_sha = f.read().strip()
  3329. commit = r.object_store[bad_sha]
  3330. assert isinstance(commit, Commit)
  3331. message = commit.message.decode(
  3332. "utf-8", errors="replace"
  3333. ).split("\n")[0]
  3334. logger.info(
  3335. "%s is the first bad commit", bad_sha.decode("ascii")
  3336. )
  3337. logger.info("commit %s", bad_sha.decode("ascii"))
  3338. logger.info(" %s", message)
  3339. elif parsed_args.subcommand == "good":
  3340. next_sha = porcelain.bisect_good(rev=parsed_args.rev)
  3341. if next_sha:
  3342. logger.info(
  3343. "Bisecting: checking out '%s'", next_sha.decode("ascii")
  3344. )
  3345. elif parsed_args.subcommand == "skip":
  3346. next_sha = porcelain.bisect_skip(
  3347. revs=parsed_args.revs if parsed_args.revs else None
  3348. )
  3349. if next_sha:
  3350. logger.info(
  3351. "Bisecting: checking out '%s'", next_sha.decode("ascii")
  3352. )
  3353. elif parsed_args.subcommand == "reset":
  3354. porcelain.bisect_reset(commit=parsed_args.commit)
  3355. logger.info("Bisect reset")
  3356. elif parsed_args.subcommand == "log":
  3357. log = porcelain.bisect_log()
  3358. logger.info(log.rstrip())
  3359. elif parsed_args.subcommand == "replay":
  3360. porcelain.bisect_replay(".", log_file=parsed_args.logfile)
  3361. logger.info("Replayed bisect log from %s", parsed_args.logfile)
  3362. elif parsed_args.subcommand == "help":
  3363. parser.print_help()
  3364. except porcelain.Error as e:
  3365. logger.error("%s", e)
  3366. return 1
  3367. except ValueError as e:
  3368. logger.error("%s", e)
  3369. return 1
  3370. return 0
  3371. class cmd_stash(SuperCommand):
  3372. """Stash the changes in a dirty working directory away."""
  3373. subcommands: ClassVar[dict[str, type[Command]]] = {
  3374. "list": cmd_stash_list,
  3375. "pop": cmd_stash_pop,
  3376. "push": cmd_stash_push,
  3377. }
  3378. class cmd_ls_files(Command):
  3379. """Show information about files in the index and working tree."""
  3380. def run(self, args: Sequence[str]) -> None:
  3381. """Execute the ls-files command.
  3382. Args:
  3383. args: Command line arguments
  3384. """
  3385. parser = argparse.ArgumentParser()
  3386. parser.parse_args(args)
  3387. for name in porcelain.ls_files("."):
  3388. logger.info(name)
  3389. class cmd_describe(Command):
  3390. """Give an object a human readable name based on an available ref."""
  3391. def run(self, args: Sequence[str]) -> None:
  3392. """Execute the describe command.
  3393. Args:
  3394. args: Command line arguments
  3395. """
  3396. parser = argparse.ArgumentParser()
  3397. parser.parse_args(args)
  3398. logger.info(porcelain.describe("."))
  3399. class cmd_merge(Command):
  3400. """Join two or more development histories together."""
  3401. def run(self, args: Sequence[str]) -> int | None:
  3402. """Execute the merge command.
  3403. Args:
  3404. args: Command line arguments
  3405. """
  3406. parser = argparse.ArgumentParser()
  3407. parser.add_argument("commit", type=str, nargs="+", help="Commit(s) to merge")
  3408. parser.add_argument(
  3409. "--no-commit", action="store_true", help="Do not create a merge commit"
  3410. )
  3411. parser.add_argument(
  3412. "--no-ff", action="store_true", help="Force create a merge commit"
  3413. )
  3414. parser.add_argument("-m", "--message", type=str, help="Merge commit message")
  3415. parsed_args = parser.parse_args(args)
  3416. try:
  3417. # If multiple commits are provided, pass them as a list
  3418. # If only one commit is provided, pass it as a string
  3419. if len(parsed_args.commit) == 1:
  3420. committish = parsed_args.commit[0]
  3421. else:
  3422. committish = parsed_args.commit
  3423. merge_commit_id, conflicts = porcelain.merge(
  3424. ".",
  3425. committish,
  3426. no_commit=parsed_args.no_commit,
  3427. no_ff=parsed_args.no_ff,
  3428. message=parsed_args.message,
  3429. )
  3430. if conflicts:
  3431. logger.warning("Merge conflicts in %d file(s):", len(conflicts))
  3432. for conflict_path in conflicts:
  3433. logger.warning(" %s", conflict_path.decode())
  3434. if len(parsed_args.commit) > 1:
  3435. logger.error(
  3436. "Octopus merge failed; refusing to merge with conflicts."
  3437. )
  3438. else:
  3439. logger.error(
  3440. "Automatic merge failed; fix conflicts and then commit the result."
  3441. )
  3442. return 1
  3443. elif merge_commit_id is None and not parsed_args.no_commit:
  3444. logger.info("Already up to date.")
  3445. elif parsed_args.no_commit:
  3446. logger.info("Automatic merge successful; not committing as requested.")
  3447. else:
  3448. assert merge_commit_id is not None
  3449. if len(parsed_args.commit) > 1:
  3450. logger.info(
  3451. "Octopus merge successful. Created merge commit %s",
  3452. merge_commit_id.decode(),
  3453. )
  3454. else:
  3455. logger.info(
  3456. "Merge successful. Created merge commit %s",
  3457. merge_commit_id.decode(),
  3458. )
  3459. return 0
  3460. except porcelain.Error as e:
  3461. logger.error("%s", e)
  3462. return 1
  3463. class cmd_merge_base(Command):
  3464. """Find the best common ancestor between commits."""
  3465. def run(self, args: Sequence[str]) -> int | None:
  3466. """Execute the merge-base command.
  3467. Args:
  3468. args: Command line arguments
  3469. """
  3470. parser = argparse.ArgumentParser(
  3471. description="Find the best common ancestor between commits",
  3472. prog="dulwich merge-base",
  3473. )
  3474. parser.add_argument("commits", nargs="+", help="Commits to find merge base for")
  3475. parser.add_argument("--all", action="store_true", help="Output all merge bases")
  3476. parser.add_argument(
  3477. "--octopus",
  3478. action="store_true",
  3479. help="Compute common ancestor of all commits",
  3480. )
  3481. parser.add_argument(
  3482. "--is-ancestor",
  3483. action="store_true",
  3484. help="Check if first commit is ancestor of second",
  3485. )
  3486. parser.add_argument(
  3487. "--independent",
  3488. action="store_true",
  3489. help="List commits not reachable from others",
  3490. )
  3491. parsed_args = parser.parse_args(args)
  3492. try:
  3493. if parsed_args.is_ancestor:
  3494. if len(parsed_args.commits) != 2:
  3495. logger.error("--is-ancestor requires exactly two commits")
  3496. return 1
  3497. is_anc = porcelain.is_ancestor(
  3498. ".",
  3499. ancestor=parsed_args.commits[0],
  3500. descendant=parsed_args.commits[1],
  3501. )
  3502. return 0 if is_anc else 1
  3503. elif parsed_args.independent:
  3504. commits = porcelain.independent_commits(".", parsed_args.commits)
  3505. for commit_id in commits:
  3506. print(commit_id.decode())
  3507. return 0
  3508. else:
  3509. if len(parsed_args.commits) < 2:
  3510. logger.error("At least two commits are required")
  3511. return 1
  3512. merge_bases = porcelain.merge_base(
  3513. ".",
  3514. parsed_args.commits,
  3515. all=parsed_args.all,
  3516. octopus=parsed_args.octopus,
  3517. )
  3518. for commit_id in merge_bases:
  3519. print(commit_id.decode())
  3520. return 0
  3521. except (ValueError, KeyError) as e:
  3522. logger.error("%s", e)
  3523. return 1
  3524. class cmd_notes_add(Command):
  3525. """Add notes to a commit."""
  3526. def run(self, args: Sequence[str]) -> None:
  3527. """Execute the notes-add command.
  3528. Args:
  3529. args: Command line arguments
  3530. """
  3531. parser = argparse.ArgumentParser()
  3532. parser.add_argument("object", help="Object to annotate")
  3533. parser.add_argument("-m", "--message", help="Note message", required=True)
  3534. parser.add_argument(
  3535. "--ref", default="commits", help="Notes ref (default: commits)"
  3536. )
  3537. parsed_args = parser.parse_args(args)
  3538. porcelain.notes_add(
  3539. ".", parsed_args.object, parsed_args.message, ref=parsed_args.ref
  3540. )
  3541. class cmd_notes_show(Command):
  3542. """Show notes for a commit."""
  3543. def run(self, args: Sequence[str]) -> None:
  3544. """Execute the notes-show command.
  3545. Args:
  3546. args: Command line arguments
  3547. """
  3548. parser = argparse.ArgumentParser()
  3549. parser.add_argument("object", help="Object to show notes for")
  3550. parser.add_argument(
  3551. "--ref", default="commits", help="Notes ref (default: commits)"
  3552. )
  3553. parsed_args = parser.parse_args(args)
  3554. note = porcelain.notes_show(".", parsed_args.object, ref=parsed_args.ref)
  3555. if note:
  3556. sys.stdout.buffer.write(note)
  3557. else:
  3558. logger.info("No notes found for object %s", parsed_args.object)
  3559. class cmd_notes_remove(Command):
  3560. """Remove notes for a commit."""
  3561. def run(self, args: Sequence[str]) -> None:
  3562. """Execute the notes-remove command.
  3563. Args:
  3564. args: Command line arguments
  3565. """
  3566. parser = argparse.ArgumentParser()
  3567. parser.add_argument("object", help="Object to remove notes from")
  3568. parser.add_argument(
  3569. "--ref", default="commits", help="Notes ref (default: commits)"
  3570. )
  3571. parsed_args = parser.parse_args(args)
  3572. result = porcelain.notes_remove(".", parsed_args.object, ref=parsed_args.ref)
  3573. if result:
  3574. logger.info("Removed notes for object %s", parsed_args.object)
  3575. else:
  3576. logger.info("No notes found for object %s", parsed_args.object)
  3577. class cmd_notes_list(Command):
  3578. """List all note objects."""
  3579. def run(self, args: Sequence[str]) -> None:
  3580. """Execute the notes-list command.
  3581. Args:
  3582. args: Command line arguments
  3583. """
  3584. parser = argparse.ArgumentParser()
  3585. parser.add_argument(
  3586. "--ref", default="commits", help="Notes ref (default: commits)"
  3587. )
  3588. parsed_args = parser.parse_args(args)
  3589. notes = porcelain.notes_list(".", ref=parsed_args.ref)
  3590. for object_sha, note_content in notes:
  3591. logger.info(object_sha.hex())
  3592. class cmd_notes(SuperCommand):
  3593. """Add or inspect object notes."""
  3594. subcommands: ClassVar[dict[str, type[Command]]] = {
  3595. "add": cmd_notes_add,
  3596. "show": cmd_notes_show,
  3597. "remove": cmd_notes_remove,
  3598. "list": cmd_notes_list,
  3599. }
  3600. default_command = cmd_notes_list
  3601. class cmd_replace_list(Command):
  3602. """List all replacement refs."""
  3603. def run(self, args: Sequence[str]) -> None:
  3604. """Execute the replace-list command.
  3605. Args:
  3606. args: Command line arguments
  3607. """
  3608. parser = argparse.ArgumentParser()
  3609. parser.parse_args(args)
  3610. replacements = porcelain.replace_list(".")
  3611. for object_sha, replacement_sha in replacements:
  3612. sys.stdout.write(
  3613. f"{object_sha.decode('ascii')} -> {replacement_sha.decode('ascii')}\n"
  3614. )
  3615. class cmd_replace_delete(Command):
  3616. """Delete a replacement ref."""
  3617. def run(self, args: Sequence[str]) -> int | None:
  3618. """Execute the replace-delete command.
  3619. Args:
  3620. args: Command line arguments
  3621. Returns:
  3622. Exit code (0 for success, 1 for error)
  3623. """
  3624. parser = argparse.ArgumentParser()
  3625. parser.add_argument("object", help="Object whose replacement should be removed")
  3626. parsed_args = parser.parse_args(args)
  3627. try:
  3628. porcelain.replace_delete(".", parsed_args.object)
  3629. logger.info("Deleted replacement for %s", parsed_args.object)
  3630. return None
  3631. except KeyError as e:
  3632. logger.error(str(e))
  3633. return 1
  3634. class cmd_replace(SuperCommand):
  3635. """Create, list, and delete replacement refs."""
  3636. subcommands: ClassVar[dict[str, type[Command]]] = {
  3637. "list": cmd_replace_list,
  3638. "delete": cmd_replace_delete,
  3639. }
  3640. default_command = cmd_replace_list
  3641. def run(self, args: Sequence[str]) -> int | None:
  3642. """Execute the replace command.
  3643. Args:
  3644. args: Command line arguments
  3645. Returns:
  3646. Exit code (0 for success, 1 for error)
  3647. """
  3648. # Special case: if we have exactly 2 args and no subcommand, treat as create
  3649. if len(args) == 2 and args[0] not in self.subcommands:
  3650. # This is the create form: git replace <object> <replacement>
  3651. parser = argparse.ArgumentParser()
  3652. parser.add_argument("object", help="Object to replace")
  3653. parser.add_argument("replacement", help="Replacement object")
  3654. parsed_args = parser.parse_args(args)
  3655. porcelain.replace_create(".", parsed_args.object, parsed_args.replacement)
  3656. logger.info(
  3657. "Created replacement: %s -> %s",
  3658. parsed_args.object,
  3659. parsed_args.replacement,
  3660. )
  3661. return None
  3662. # Otherwise, delegate to supercommand handling
  3663. return super().run(args)
  3664. class cmd_cherry(Command):
  3665. """Find commits not merged upstream."""
  3666. def run(self, args: Sequence[str]) -> int | None:
  3667. """Execute the cherry command.
  3668. Args:
  3669. args: Command line arguments
  3670. Returns:
  3671. Exit code (0 for success, 1 for error)
  3672. """
  3673. parser = argparse.ArgumentParser(description="Find commits not merged upstream")
  3674. parser.add_argument(
  3675. "-v",
  3676. "--verbose",
  3677. action="store_true",
  3678. help="Show commit messages",
  3679. )
  3680. parser.add_argument(
  3681. "upstream",
  3682. nargs="?",
  3683. help="Upstream branch (default: tracking branch or HEAD^)",
  3684. )
  3685. parser.add_argument(
  3686. "head",
  3687. nargs="?",
  3688. help="Head branch (default: HEAD)",
  3689. )
  3690. parser.add_argument(
  3691. "limit",
  3692. nargs="?",
  3693. help="Limit commits to those after this ref",
  3694. )
  3695. parsed_args = parser.parse_args(args)
  3696. try:
  3697. results = porcelain.cherry(
  3698. ".",
  3699. upstream=parsed_args.upstream,
  3700. head=parsed_args.head,
  3701. limit=parsed_args.limit,
  3702. verbose=parsed_args.verbose,
  3703. )
  3704. except (NotGitRepository, OSError, FileFormatException, ValueError) as e:
  3705. logger.error(f"Error: {e}")
  3706. return 1
  3707. # Output results
  3708. for status, commit_sha, message in results:
  3709. # Convert commit_sha to hex string
  3710. if isinstance(commit_sha, bytes):
  3711. commit_hex = commit_sha.hex()
  3712. else:
  3713. commit_hex = commit_sha
  3714. if parsed_args.verbose and message:
  3715. message_str = message.decode("utf-8", errors="replace")
  3716. logger.info(f"{status} {commit_hex} {message_str}")
  3717. else:
  3718. logger.info(f"{status} {commit_hex}")
  3719. return 0
  3720. class cmd_cherry_pick(Command):
  3721. """Apply the changes introduced by some existing commits."""
  3722. def run(self, args: Sequence[str]) -> int | None:
  3723. """Execute the cherry-pick command.
  3724. Args:
  3725. args: Command line arguments
  3726. """
  3727. parser = argparse.ArgumentParser(
  3728. description="Apply the changes introduced by some existing commits"
  3729. )
  3730. parser.add_argument("commit", nargs="?", help="Commit to cherry-pick")
  3731. parser.add_argument(
  3732. "-n",
  3733. "--no-commit",
  3734. action="store_true",
  3735. help="Apply changes without making a commit",
  3736. )
  3737. parser.add_argument(
  3738. "--continue",
  3739. dest="continue_",
  3740. action="store_true",
  3741. help="Continue after resolving conflicts",
  3742. )
  3743. parser.add_argument(
  3744. "--abort",
  3745. action="store_true",
  3746. help="Abort the current cherry-pick operation",
  3747. )
  3748. parsed_args = parser.parse_args(args)
  3749. # Check argument validity
  3750. if parsed_args.continue_ or parsed_args.abort:
  3751. if parsed_args.commit is not None:
  3752. parser.error("Cannot specify commit with --continue or --abort")
  3753. return 1
  3754. else:
  3755. if parsed_args.commit is None:
  3756. parser.error("Commit argument is required")
  3757. return 1
  3758. try:
  3759. commit_arg = parsed_args.commit
  3760. result = porcelain.cherry_pick(
  3761. ".",
  3762. commit_arg,
  3763. no_commit=parsed_args.no_commit,
  3764. continue_=parsed_args.continue_,
  3765. abort=parsed_args.abort,
  3766. )
  3767. if parsed_args.abort:
  3768. logger.info("Cherry-pick aborted.")
  3769. elif parsed_args.continue_:
  3770. if result:
  3771. logger.info("Cherry-pick completed: %s", result.decode())
  3772. else:
  3773. logger.info("Cherry-pick completed.")
  3774. elif result is None:
  3775. if parsed_args.no_commit:
  3776. logger.info("Cherry-pick applied successfully (no commit created).")
  3777. else:
  3778. # This shouldn't happen unless there were conflicts
  3779. logger.warning("Cherry-pick resulted in conflicts.")
  3780. else:
  3781. logger.info("Cherry-pick successful: %s", result.decode())
  3782. return None
  3783. except porcelain.Error as e:
  3784. logger.error("%s", e)
  3785. return 1
  3786. class cmd_merge_tree(Command):
  3787. """Show three-way merge without touching index."""
  3788. def run(self, args: Sequence[str]) -> int | None:
  3789. """Execute the merge-tree command.
  3790. Args:
  3791. args: Command line arguments
  3792. """
  3793. parser = argparse.ArgumentParser(
  3794. description="Perform a tree-level merge without touching the working directory"
  3795. )
  3796. parser.add_argument(
  3797. "base_tree",
  3798. nargs="?",
  3799. help="The common ancestor tree (optional, defaults to empty tree)",
  3800. )
  3801. parser.add_argument("our_tree", help="Our side of the merge")
  3802. parser.add_argument("their_tree", help="Their side of the merge")
  3803. parser.add_argument(
  3804. "-z",
  3805. "--name-only",
  3806. action="store_true",
  3807. help="Output only conflict paths, null-terminated",
  3808. )
  3809. parsed_args = parser.parse_args(args)
  3810. try:
  3811. # Determine base tree - if only two parsed_args provided, base is None
  3812. if parsed_args.base_tree is None:
  3813. # Only two arguments provided
  3814. base_tree = None
  3815. our_tree = parsed_args.our_tree
  3816. their_tree = parsed_args.their_tree
  3817. else:
  3818. # Three arguments provided
  3819. base_tree = parsed_args.base_tree
  3820. our_tree = parsed_args.our_tree
  3821. their_tree = parsed_args.their_tree
  3822. merged_tree_id, conflicts = porcelain.merge_tree(
  3823. ".", base_tree, our_tree, their_tree
  3824. )
  3825. if parsed_args.name_only:
  3826. # Output only conflict paths, null-terminated
  3827. for conflict_path in conflicts:
  3828. sys.stdout.buffer.write(conflict_path)
  3829. sys.stdout.buffer.write(b"\0")
  3830. else:
  3831. # Output the merged tree SHA
  3832. logger.info(merged_tree_id.decode("ascii"))
  3833. # Output conflict information
  3834. if conflicts:
  3835. logger.warning("\nConflicts in %d file(s):", len(conflicts))
  3836. for conflict_path in conflicts:
  3837. logger.warning(" %s", conflict_path.decode())
  3838. return None
  3839. except porcelain.Error as e:
  3840. logger.error("%s", e)
  3841. return 1
  3842. except KeyError as e:
  3843. logger.error("Object not found: %s", e)
  3844. return 1
  3845. class cmd_gc(Command):
  3846. """Cleanup unnecessary files and optimize the local repository."""
  3847. def run(self, args: Sequence[str]) -> int | None:
  3848. """Execute the gc command.
  3849. Args:
  3850. args: Command line arguments
  3851. """
  3852. import datetime
  3853. import time
  3854. parser = argparse.ArgumentParser()
  3855. parser.add_argument(
  3856. "--auto",
  3857. action="store_true",
  3858. help="Only run gc if needed",
  3859. )
  3860. parser.add_argument(
  3861. "--aggressive",
  3862. action="store_true",
  3863. help="Use more aggressive settings",
  3864. )
  3865. parser.add_argument(
  3866. "--no-prune",
  3867. action="store_true",
  3868. help="Do not prune unreachable objects",
  3869. )
  3870. parser.add_argument(
  3871. "--prune",
  3872. nargs="?",
  3873. const="now",
  3874. help="Prune unreachable objects older than date (default: 2 weeks ago)",
  3875. )
  3876. parser.add_argument(
  3877. "--dry-run",
  3878. "-n",
  3879. action="store_true",
  3880. help="Only report what would be done",
  3881. )
  3882. parser.add_argument(
  3883. "--quiet",
  3884. "-q",
  3885. action="store_true",
  3886. help="Only report errors",
  3887. )
  3888. parsed_args = parser.parse_args(args)
  3889. # Parse prune grace period
  3890. grace_period = None
  3891. if parsed_args.prune:
  3892. from .approxidate import parse_relative_time
  3893. try:
  3894. grace_period = parse_relative_time(parsed_args.prune)
  3895. except ValueError:
  3896. # Try to parse as absolute date
  3897. try:
  3898. date = datetime.datetime.strptime(parsed_args.prune, "%Y-%m-%d")
  3899. grace_period = int(time.time() - date.timestamp())
  3900. except ValueError:
  3901. logger.error("Invalid prune date: %s", parsed_args.prune)
  3902. return 1
  3903. elif not parsed_args.no_prune:
  3904. # Default to 2 weeks
  3905. grace_period = 1209600
  3906. # Progress callback
  3907. def progress(msg: str) -> None:
  3908. if not parsed_args.quiet:
  3909. logger.info(msg)
  3910. try:
  3911. stats = porcelain.gc(
  3912. ".",
  3913. auto=parsed_args.auto,
  3914. aggressive=parsed_args.aggressive,
  3915. prune=not parsed_args.no_prune,
  3916. grace_period=grace_period,
  3917. dry_run=parsed_args.dry_run,
  3918. progress=progress if not parsed_args.quiet else None,
  3919. )
  3920. # Report results
  3921. if not parsed_args.quiet:
  3922. if parsed_args.dry_run:
  3923. logger.info("\nDry run results:")
  3924. if not parsed_args.quiet:
  3925. if parsed_args.dry_run:
  3926. print("\nDry run results:")
  3927. else:
  3928. logger.info("\nGarbage collection complete:")
  3929. if stats.pruned_objects:
  3930. logger.info(
  3931. " Pruned %d unreachable objects", len(stats.pruned_objects)
  3932. )
  3933. logger.info(" Freed %s", format_bytes(stats.bytes_freed))
  3934. if stats.packs_before != stats.packs_after:
  3935. logger.info(
  3936. " Reduced pack files from %d to %d",
  3937. stats.packs_before,
  3938. stats.packs_after,
  3939. )
  3940. except porcelain.Error as e:
  3941. logger.error("%s", e)
  3942. return 1
  3943. return None
  3944. class cmd_maintenance(Command):
  3945. """Run tasks to optimize Git repository data."""
  3946. def run(self, args: Sequence[str]) -> int | None:
  3947. """Execute the maintenance command.
  3948. Args:
  3949. args: Command line arguments
  3950. """
  3951. parser = argparse.ArgumentParser(
  3952. description="Run tasks to optimize Git repository data"
  3953. )
  3954. subparsers = parser.add_subparsers(
  3955. dest="subcommand", help="Maintenance subcommand"
  3956. )
  3957. # maintenance run subcommand
  3958. run_parser = subparsers.add_parser("run", help="Run maintenance tasks")
  3959. run_parser.add_argument(
  3960. "--task",
  3961. action="append",
  3962. dest="tasks",
  3963. help="Run a specific task (can be specified multiple times)",
  3964. )
  3965. run_parser.add_argument(
  3966. "--auto",
  3967. action="store_true",
  3968. help="Only run tasks if needed",
  3969. )
  3970. run_parser.add_argument(
  3971. "--quiet",
  3972. "-q",
  3973. action="store_true",
  3974. help="Only report errors",
  3975. )
  3976. # maintenance start subcommand (placeholder)
  3977. subparsers.add_parser("start", help="Start background maintenance")
  3978. # maintenance stop subcommand (placeholder)
  3979. subparsers.add_parser("stop", help="Stop background maintenance")
  3980. # maintenance register subcommand
  3981. subparsers.add_parser("register", help="Register repository for maintenance")
  3982. # maintenance unregister subcommand
  3983. unregister_parser = subparsers.add_parser(
  3984. "unregister", help="Unregister repository from maintenance"
  3985. )
  3986. unregister_parser.add_argument(
  3987. "--force",
  3988. action="store_true",
  3989. help="Don't error if repository is not registered",
  3990. )
  3991. parsed_args = parser.parse_args(args)
  3992. if not parsed_args.subcommand:
  3993. parser.print_help()
  3994. return 1
  3995. if parsed_args.subcommand == "run":
  3996. # Progress callback
  3997. def progress(msg: str) -> None:
  3998. if not parsed_args.quiet:
  3999. logger.info(msg)
  4000. try:
  4001. result = porcelain.maintenance_run(
  4002. ".",
  4003. tasks=parsed_args.tasks,
  4004. auto=parsed_args.auto,
  4005. progress=progress if not parsed_args.quiet else None,
  4006. )
  4007. # Report results
  4008. if not parsed_args.quiet:
  4009. if result.tasks_succeeded:
  4010. logger.info("\nSuccessfully completed tasks:")
  4011. for task in result.tasks_succeeded:
  4012. logger.info(f" - {task}")
  4013. if result.tasks_failed:
  4014. logger.error("\nFailed tasks:")
  4015. for task in result.tasks_failed:
  4016. error_msg = result.errors.get(task, "Unknown error")
  4017. logger.error(f" - {task}: {error_msg}")
  4018. return 1
  4019. except porcelain.Error as e:
  4020. logger.error("%s", e)
  4021. return 1
  4022. elif parsed_args.subcommand == "register":
  4023. porcelain.maintenance_register(".")
  4024. logger.info("Repository registered for background maintenance")
  4025. elif parsed_args.subcommand == "unregister":
  4026. try:
  4027. force = getattr(parsed_args, "force", False)
  4028. porcelain.maintenance_unregister(".", force=force)
  4029. except ValueError as e:
  4030. logger.error(str(e))
  4031. return 1
  4032. logger.info("Repository unregistered from background maintenance")
  4033. elif parsed_args.subcommand in ("start", "stop"):
  4034. # TODO: Implement background maintenance scheduling
  4035. logger.error(
  4036. f"The '{parsed_args.subcommand}' subcommand is not yet implemented"
  4037. )
  4038. return 1
  4039. else:
  4040. parser.print_help()
  4041. return 1
  4042. return None
  4043. class cmd_grep(Command):
  4044. """Search for patterns in tracked files."""
  4045. def run(self, args: Sequence[str]) -> None:
  4046. """Execute the grep command.
  4047. Args:
  4048. args: Command line arguments
  4049. """
  4050. parser = argparse.ArgumentParser()
  4051. parser.add_argument("pattern", help="Regular expression pattern to search for")
  4052. parser.add_argument(
  4053. "revision",
  4054. nargs="?",
  4055. default=None,
  4056. help="Revision to search (defaults to HEAD)",
  4057. )
  4058. parser.add_argument(
  4059. "pathspecs",
  4060. nargs="*",
  4061. help="Path patterns to limit search",
  4062. )
  4063. parser.add_argument(
  4064. "-i",
  4065. "--ignore-case",
  4066. action="store_true",
  4067. help="Perform case-insensitive matching",
  4068. )
  4069. parser.add_argument(
  4070. "-n",
  4071. "--line-number",
  4072. action="store_true",
  4073. help="Show line numbers for matches",
  4074. )
  4075. parser.add_argument(
  4076. "--max-depth",
  4077. type=int,
  4078. default=None,
  4079. help="Maximum directory depth to search",
  4080. )
  4081. parser.add_argument(
  4082. "--no-ignore",
  4083. action="store_true",
  4084. help="Do not respect .gitignore patterns",
  4085. )
  4086. parsed_args = parser.parse_args(args)
  4087. # Handle the case where revision might be a pathspec
  4088. revision = parsed_args.revision
  4089. pathspecs = parsed_args.pathspecs
  4090. # If revision looks like a pathspec (contains wildcards or slashes),
  4091. # treat it as a pathspec instead
  4092. if revision and ("*" in revision or "/" in revision or "." in revision):
  4093. pathspecs = [revision, *pathspecs]
  4094. revision = None
  4095. with Repo(".") as repo:
  4096. config = repo.get_config_stack()
  4097. with get_pager(config=config, cmd_name="grep") as outstream:
  4098. porcelain.grep(
  4099. repo,
  4100. parsed_args.pattern,
  4101. outstream=outstream,
  4102. rev=revision,
  4103. pathspecs=pathspecs if pathspecs else None,
  4104. ignore_case=parsed_args.ignore_case,
  4105. line_number=parsed_args.line_number,
  4106. max_depth=parsed_args.max_depth,
  4107. respect_ignores=not parsed_args.no_ignore,
  4108. )
  4109. class cmd_count_objects(Command):
  4110. """Count unpacked number of objects and their disk consumption."""
  4111. def run(self, args: Sequence[str]) -> None:
  4112. """Execute the count-objects command.
  4113. Args:
  4114. args: Command line arguments
  4115. """
  4116. parser = argparse.ArgumentParser()
  4117. parser.add_argument(
  4118. "-v",
  4119. "--verbose",
  4120. action="store_true",
  4121. help="Display verbose information.",
  4122. )
  4123. parsed_args = parser.parse_args(args)
  4124. if parsed_args.verbose:
  4125. stats = porcelain.count_objects(".", verbose=True)
  4126. # Display verbose output
  4127. logger.info("count: %d", stats.count)
  4128. logger.info("size: %d", stats.size // 1024) # Size in KiB
  4129. assert stats.in_pack is not None
  4130. logger.info("in-pack: %d", stats.in_pack)
  4131. assert stats.packs is not None
  4132. logger.info("packs: %d", stats.packs)
  4133. assert stats.size_pack is not None
  4134. logger.info("size-pack: %d", stats.size_pack // 1024) # Size in KiB
  4135. else:
  4136. # Simple output
  4137. stats = porcelain.count_objects(".", verbose=False)
  4138. logger.info("%d objects, %d kilobytes", stats.count, stats.size // 1024)
  4139. class cmd_rebase(Command):
  4140. """Reapply commits on top of another base tip."""
  4141. def run(self, args: Sequence[str]) -> int:
  4142. """Execute the rebase command.
  4143. Args:
  4144. args: Command line arguments
  4145. """
  4146. parser = argparse.ArgumentParser()
  4147. parser.add_argument(
  4148. "upstream", nargs="?", help="Upstream branch to rebase onto"
  4149. )
  4150. parser.add_argument("--onto", type=str, help="Rebase onto specific commit")
  4151. parser.add_argument(
  4152. "--branch", type=str, help="Branch to rebase (default: current)"
  4153. )
  4154. parser.add_argument(
  4155. "-i", "--interactive", action="store_true", help="Interactive rebase"
  4156. )
  4157. parser.add_argument(
  4158. "--edit-todo",
  4159. action="store_true",
  4160. help="Edit the todo list during an interactive rebase",
  4161. )
  4162. parser.add_argument(
  4163. "--abort", action="store_true", help="Abort an in-progress rebase"
  4164. )
  4165. parser.add_argument(
  4166. "--continue",
  4167. dest="continue_rebase",
  4168. action="store_true",
  4169. help="Continue an in-progress rebase",
  4170. )
  4171. parser.add_argument(
  4172. "--skip", action="store_true", help="Skip current commit and continue"
  4173. )
  4174. parsed_args = parser.parse_args(args)
  4175. # Handle abort/continue/skip first
  4176. if parsed_args.abort:
  4177. try:
  4178. porcelain.rebase(".", parsed_args.upstream or "HEAD", abort=True)
  4179. logger.info("Rebase aborted.")
  4180. except porcelain.Error as e:
  4181. logger.error("%s", e)
  4182. return 1
  4183. return 0
  4184. if parsed_args.continue_rebase:
  4185. try:
  4186. # Check if interactive rebase is in progress
  4187. if porcelain.is_interactive_rebase("."):
  4188. result = porcelain.rebase(
  4189. ".",
  4190. parsed_args.upstream or "HEAD",
  4191. continue_rebase=True,
  4192. interactive=True,
  4193. )
  4194. if result:
  4195. logger.info("Rebase complete.")
  4196. else:
  4197. logger.info("Rebase paused. Use --continue to resume.")
  4198. else:
  4199. new_shas = porcelain.rebase(
  4200. ".", parsed_args.upstream or "HEAD", continue_rebase=True
  4201. )
  4202. logger.info("Rebase complete.")
  4203. except porcelain.Error as e:
  4204. logger.error("%s", e)
  4205. return 1
  4206. return 0
  4207. if parsed_args.edit_todo:
  4208. # Edit todo list for interactive rebase
  4209. try:
  4210. porcelain.rebase(".", parsed_args.upstream or "HEAD", edit_todo=True)
  4211. logger.info("Todo list updated.")
  4212. except porcelain.Error as e:
  4213. logger.error("%s", e)
  4214. return 1
  4215. return 0
  4216. # Normal rebase requires upstream
  4217. if not parsed_args.upstream:
  4218. logger.error("Missing required argument 'upstream'")
  4219. return 1
  4220. try:
  4221. if parsed_args.interactive:
  4222. # Interactive rebase
  4223. result = porcelain.rebase(
  4224. ".",
  4225. parsed_args.upstream,
  4226. onto=parsed_args.onto,
  4227. branch=parsed_args.branch,
  4228. interactive=True,
  4229. )
  4230. if result:
  4231. logger.info(
  4232. "Interactive rebase started. Edit the todo list and save."
  4233. )
  4234. else:
  4235. logger.info("No commits to rebase.")
  4236. else:
  4237. # Regular rebase
  4238. new_shas = porcelain.rebase(
  4239. ".",
  4240. parsed_args.upstream,
  4241. onto=parsed_args.onto,
  4242. branch=parsed_args.branch,
  4243. )
  4244. if new_shas:
  4245. logger.info("Successfully rebased %d commits.", len(new_shas))
  4246. else:
  4247. logger.info("Already up to date.")
  4248. return 0
  4249. except porcelain.Error as e:
  4250. logger.error("%s", e)
  4251. return 1
  4252. class cmd_filter_branch(Command):
  4253. """Rewrite branches."""
  4254. def run(self, args: Sequence[str]) -> int | None:
  4255. """Execute the filter-branch command.
  4256. Args:
  4257. args: Command line arguments
  4258. """
  4259. import subprocess
  4260. parser = argparse.ArgumentParser(description="Rewrite branches")
  4261. # Supported Git-compatible options
  4262. parser.add_argument(
  4263. "--subdirectory-filter",
  4264. type=str,
  4265. help="Only include history for subdirectory",
  4266. )
  4267. parser.add_argument("--env-filter", type=str, help="Environment filter command")
  4268. parser.add_argument("--tree-filter", type=str, help="Tree filter command")
  4269. parser.add_argument("--index-filter", type=str, help="Index filter command")
  4270. parser.add_argument("--parent-filter", type=str, help="Parent filter command")
  4271. parser.add_argument("--msg-filter", type=str, help="Message filter command")
  4272. parser.add_argument("--commit-filter", type=str, help="Commit filter command")
  4273. parser.add_argument(
  4274. "--tag-name-filter", type=str, help="Tag name filter command"
  4275. )
  4276. parser.add_argument(
  4277. "--prune-empty", action="store_true", help="Remove empty commits"
  4278. )
  4279. parser.add_argument(
  4280. "--original",
  4281. type=str,
  4282. default="refs/original",
  4283. help="Namespace for original refs",
  4284. )
  4285. parser.add_argument(
  4286. "-f",
  4287. "--force",
  4288. action="store_true",
  4289. help="Force operation even if refs/original/* exists",
  4290. )
  4291. # Branch/ref to rewrite (defaults to HEAD)
  4292. parser.add_argument(
  4293. "branch", nargs="?", default="HEAD", help="Branch or ref to rewrite"
  4294. )
  4295. parsed_args = parser.parse_args(args)
  4296. # Track if any filter fails
  4297. filter_error = False
  4298. # Setup environment for filters
  4299. env = os.environ.copy()
  4300. # Helper function to run shell commands
  4301. def run_filter(
  4302. cmd: str,
  4303. input_data: bytes | None = None,
  4304. cwd: str | None = None,
  4305. extra_env: dict[str, str] | None = None,
  4306. ) -> bytes | None:
  4307. nonlocal filter_error
  4308. filter_env = env.copy()
  4309. if extra_env:
  4310. filter_env.update(extra_env)
  4311. result = subprocess.run(
  4312. cmd,
  4313. shell=True,
  4314. input=input_data,
  4315. cwd=cwd,
  4316. env=filter_env,
  4317. capture_output=True,
  4318. )
  4319. if result.returncode != 0:
  4320. filter_error = True
  4321. return None
  4322. return result.stdout
  4323. # Create filter functions based on arguments
  4324. filter_message = None
  4325. if parsed_args.msg_filter:
  4326. def filter_message(message: bytes) -> bytes:
  4327. result = run_filter(parsed_args.msg_filter, input_data=message)
  4328. return result if result is not None else message
  4329. tree_filter = None
  4330. if parsed_args.tree_filter:
  4331. def tree_filter(tree_sha: bytes, tmpdir: str) -> bytes:
  4332. from dulwich.objects import Blob, Tree
  4333. # Export tree to tmpdir
  4334. with Repo(".") as r:
  4335. tree = r.object_store[tree_sha]
  4336. assert isinstance(tree, Tree)
  4337. for entry in tree.iteritems():
  4338. assert entry.path is not None
  4339. assert entry.sha is not None
  4340. path = Path(tmpdir) / entry.path.decode()
  4341. obj = r.object_store[entry.sha]
  4342. if isinstance(obj, Tree):
  4343. path.mkdir(exist_ok=True)
  4344. else:
  4345. assert isinstance(obj, Blob)
  4346. path.write_bytes(obj.data)
  4347. # Run the filter command in the temp directory
  4348. run_filter(parsed_args.tree_filter, cwd=tmpdir)
  4349. # Rebuild tree from modified temp directory
  4350. def build_tree_from_dir(dir_path: str) -> bytes:
  4351. tree = Tree()
  4352. for name in sorted(os.listdir(dir_path)):
  4353. if name.startswith("."):
  4354. continue
  4355. path = os.path.join(dir_path, name)
  4356. if os.path.isdir(path):
  4357. subtree_sha = build_tree_from_dir(path)
  4358. tree.add(name.encode(), 0o040000, subtree_sha)
  4359. else:
  4360. with open(path, "rb") as f:
  4361. data = f.read()
  4362. blob = Blob.from_string(data)
  4363. r.object_store.add_object(blob)
  4364. # Use appropriate file mode
  4365. mode = os.stat(path).st_mode
  4366. if mode & 0o100:
  4367. file_mode = 0o100755
  4368. else:
  4369. file_mode = 0o100644
  4370. tree.add(name.encode(), file_mode, blob.id)
  4371. r.object_store.add_object(tree)
  4372. return tree.id
  4373. return build_tree_from_dir(tmpdir)
  4374. index_filter = None
  4375. if parsed_args.index_filter:
  4376. def index_filter(tree_sha: bytes, index_path: str) -> bytes | None:
  4377. run_filter(
  4378. parsed_args.index_filter, extra_env={"GIT_INDEX_FILE": index_path}
  4379. )
  4380. return None # Read back from index
  4381. parent_filter = None
  4382. if parsed_args.parent_filter:
  4383. def parent_filter(parents: Sequence[bytes]) -> list[bytes]:
  4384. parent_str = " ".join(p.hex() for p in parents)
  4385. result = run_filter(
  4386. parsed_args.parent_filter, input_data=parent_str.encode()
  4387. )
  4388. if result is None:
  4389. return list(parents)
  4390. output = result.decode().strip()
  4391. if not output:
  4392. return []
  4393. new_parents = []
  4394. for sha in output.split():
  4395. sha_bytes = sha.encode()
  4396. if valid_hexsha(sha_bytes):
  4397. new_parents.append(sha_bytes)
  4398. return new_parents
  4399. commit_filter = None
  4400. if parsed_args.commit_filter:
  4401. def commit_filter(commit_obj: Commit, tree_sha: bytes) -> bytes | None:
  4402. # The filter receives: tree parent1 parent2...
  4403. cmd_input = tree_sha.hex()
  4404. for parent in commit_obj.parents:
  4405. cmd_input += " " + parent.hex()
  4406. result = run_filter(
  4407. parsed_args.commit_filter,
  4408. input_data=cmd_input.encode(),
  4409. extra_env={"GIT_COMMIT": commit_obj.id.hex()},
  4410. )
  4411. if result is None:
  4412. return None
  4413. output = result.decode().strip()
  4414. if not output:
  4415. return None # Skip commit
  4416. if valid_hexsha(output):
  4417. return output.encode()
  4418. return None
  4419. tag_name_filter = None
  4420. if parsed_args.tag_name_filter:
  4421. def tag_name_filter(tag_name: bytes) -> bytes:
  4422. result = run_filter(parsed_args.tag_name_filter, input_data=tag_name)
  4423. return result if result is not None else tag_name
  4424. # Open repo once
  4425. with Repo(".") as r:
  4426. # Check for refs/original if not forcing
  4427. if not parsed_args.force:
  4428. original_prefix = parsed_args.original.encode() + b"/"
  4429. for ref in r.refs.allkeys():
  4430. if ref.startswith(original_prefix):
  4431. logger.error("Cannot create a new backup.")
  4432. logger.error(
  4433. "A previous backup already exists in %s/",
  4434. parsed_args.original,
  4435. )
  4436. logger.error("Force overwriting the backup with -f")
  4437. print("Cannot create a new backup.")
  4438. print(
  4439. f"A previous backup already exists in {parsed_args.original}/"
  4440. )
  4441. print("Force overwriting the backup with -f")
  4442. return 1
  4443. try:
  4444. # Call porcelain.filter_branch with the repo object
  4445. result = porcelain.filter_branch(
  4446. r,
  4447. parsed_args.branch,
  4448. filter_message=filter_message,
  4449. tree_filter=tree_filter if parsed_args.tree_filter else None,
  4450. index_filter=index_filter if parsed_args.index_filter else None,
  4451. parent_filter=parent_filter if parsed_args.parent_filter else None,
  4452. commit_filter=commit_filter if parsed_args.commit_filter else None,
  4453. subdirectory_filter=parsed_args.subdirectory_filter,
  4454. prune_empty=parsed_args.prune_empty,
  4455. tag_name_filter=tag_name_filter
  4456. if parsed_args.tag_name_filter
  4457. else None,
  4458. force=parsed_args.force,
  4459. keep_original=True, # Always keep original with git
  4460. )
  4461. # Check if any filter failed
  4462. if filter_error:
  4463. logger.error("Filter command failed")
  4464. return 1
  4465. # Git filter-branch shows progress
  4466. if result:
  4467. logger.info(
  4468. "Rewrite %s (%d commits)", parsed_args.branch, len(result)
  4469. )
  4470. # Git shows: Ref 'refs/heads/branch' was rewritten
  4471. if parsed_args.branch != "HEAD":
  4472. ref_name = (
  4473. parsed_args.branch
  4474. if parsed_args.branch.startswith("refs/")
  4475. else f"refs/heads/{parsed_args.branch}"
  4476. )
  4477. logger.info("Ref '%s' was rewritten", ref_name)
  4478. return 0
  4479. except porcelain.Error as e:
  4480. logger.error("%s", e)
  4481. return 1
  4482. class cmd_lfs(Command):
  4483. """Git Large File Storage management."""
  4484. """Git LFS management commands."""
  4485. def run(self, argv: Sequence[str]) -> None:
  4486. """Execute the lfs command.
  4487. Args:
  4488. argv: Command line arguments
  4489. """
  4490. parser = argparse.ArgumentParser(prog="dulwich lfs")
  4491. subparsers = parser.add_subparsers(dest="subcommand", help="LFS subcommands")
  4492. # lfs init
  4493. subparsers.add_parser("init", help="Initialize Git LFS")
  4494. # lfs track
  4495. parser_track = subparsers.add_parser(
  4496. "track", help="Track file patterns with LFS"
  4497. )
  4498. parser_track.add_argument("patterns", nargs="*", help="File patterns to track")
  4499. # lfs untrack
  4500. parser_untrack = subparsers.add_parser(
  4501. "untrack", help="Untrack file patterns from LFS"
  4502. )
  4503. parser_untrack.add_argument(
  4504. "patterns", nargs="+", help="File patterns to untrack"
  4505. )
  4506. # lfs ls-files
  4507. parser_ls = subparsers.add_parser("ls-files", help="List LFS files")
  4508. parser_ls.add_argument("--ref", help="Git ref to check (defaults to HEAD)")
  4509. # lfs migrate
  4510. parser_migrate = subparsers.add_parser("migrate", help="Migrate files to LFS")
  4511. parser_migrate.add_argument("--include", nargs="+", help="Patterns to include")
  4512. parser_migrate.add_argument("--exclude", nargs="+", help="Patterns to exclude")
  4513. parser_migrate.add_argument(
  4514. "--everything", action="store_true", help="Migrate all files above 100MB"
  4515. )
  4516. # lfs pointer
  4517. parser_pointer = subparsers.add_parser("pointer", help="Check LFS pointers")
  4518. parser_pointer.add_argument(
  4519. "--check", nargs="*", dest="paths", help="Check if files are LFS pointers"
  4520. )
  4521. # lfs clean
  4522. parser_clean = subparsers.add_parser("clean", help="Clean file to LFS pointer")
  4523. parser_clean.add_argument("path", help="File path to clean")
  4524. # lfs smudge
  4525. parser_smudge = subparsers.add_parser(
  4526. "smudge", help="Smudge LFS pointer to content"
  4527. )
  4528. parser_smudge.add_argument(
  4529. "--stdin", action="store_true", help="Read pointer from stdin"
  4530. )
  4531. # lfs fetch
  4532. parser_fetch = subparsers.add_parser(
  4533. "fetch", help="Fetch LFS objects from remote"
  4534. )
  4535. parser_fetch.add_argument(
  4536. "--remote", default="origin", help="Remote to fetch from"
  4537. )
  4538. parser_fetch.add_argument("refs", nargs="*", help="Specific refs to fetch")
  4539. # lfs pull
  4540. parser_pull = subparsers.add_parser(
  4541. "pull", help="Pull LFS objects for current checkout"
  4542. )
  4543. parser_pull.add_argument(
  4544. "--remote", default="origin", help="Remote to pull from"
  4545. )
  4546. # lfs push
  4547. parser_push = subparsers.add_parser("push", help="Push LFS objects to remote")
  4548. parser_push.add_argument("--remote", default="origin", help="Remote to push to")
  4549. parser_push.add_argument("refs", nargs="*", help="Specific refs to push")
  4550. # lfs status
  4551. subparsers.add_parser("status", help="Show status of LFS files")
  4552. args = parser.parse_args(argv)
  4553. if args.subcommand == "init":
  4554. porcelain.lfs_init()
  4555. logger.info("Git LFS initialized.")
  4556. elif args.subcommand == "track":
  4557. if args.patterns:
  4558. tracked = porcelain.lfs_track(patterns=args.patterns)
  4559. logger.info("Tracking patterns:")
  4560. else:
  4561. tracked = porcelain.lfs_track()
  4562. logger.info("Currently tracked patterns:")
  4563. for pattern in tracked:
  4564. logger.info(" %s", pattern)
  4565. elif args.subcommand == "untrack":
  4566. tracked = porcelain.lfs_untrack(patterns=args.patterns)
  4567. logger.info("Remaining tracked patterns:")
  4568. for pattern in tracked:
  4569. logger.info(" %s", to_display_str(pattern))
  4570. elif args.subcommand == "ls-files":
  4571. files = porcelain.lfs_ls_files(ref=args.ref)
  4572. for path, oid, size in files:
  4573. logger.info(
  4574. "%s * %s (%s)",
  4575. to_display_str(oid[:12]),
  4576. to_display_str(path),
  4577. format_bytes(size),
  4578. )
  4579. elif args.subcommand == "migrate":
  4580. count = porcelain.lfs_migrate(
  4581. include=args.include, exclude=args.exclude, everything=args.everything
  4582. )
  4583. logger.info("Migrated %d file(s) to Git LFS.", count)
  4584. elif args.subcommand == "pointer":
  4585. if args.paths is not None:
  4586. results = porcelain.lfs_pointer_check(paths=args.paths or None)
  4587. for file_path, pointer in results.items():
  4588. if pointer:
  4589. logger.info(
  4590. "%s: LFS pointer (oid: %s, size: %s)",
  4591. to_display_str(file_path),
  4592. to_display_str(pointer.oid[:12]),
  4593. format_bytes(pointer.size),
  4594. )
  4595. else:
  4596. logger.warning(
  4597. "%s: Not an LFS pointer", to_display_str(file_path)
  4598. )
  4599. elif args.subcommand == "clean":
  4600. pointer = porcelain.lfs_clean(path=args.path)
  4601. sys.stdout.buffer.write(pointer)
  4602. elif args.subcommand == "smudge":
  4603. if args.stdin:
  4604. pointer_content = sys.stdin.buffer.read()
  4605. content = porcelain.lfs_smudge(pointer_content=pointer_content)
  4606. sys.stdout.buffer.write(content)
  4607. else:
  4608. logger.error("--stdin required for smudge command")
  4609. sys.exit(1)
  4610. elif args.subcommand == "fetch":
  4611. refs = args.refs or None
  4612. count = porcelain.lfs_fetch(remote=args.remote, refs=refs)
  4613. logger.info("Fetched %d LFS object(s).", count)
  4614. elif args.subcommand == "pull":
  4615. count = porcelain.lfs_pull(remote=args.remote)
  4616. logger.info("Pulled %d LFS object(s).", count)
  4617. elif args.subcommand == "push":
  4618. refs = args.refs or None
  4619. count = porcelain.lfs_push(remote=args.remote, refs=refs)
  4620. logger.info("Pushed %d LFS object(s).", count)
  4621. elif args.subcommand == "status":
  4622. status = porcelain.lfs_status()
  4623. if status["tracked"]:
  4624. logger.info("LFS tracked files: %d", len(status["tracked"]))
  4625. if status["missing"]:
  4626. logger.warning("\nMissing LFS objects:")
  4627. for file_path in status["missing"]:
  4628. logger.warning(" %s", to_display_str(file_path))
  4629. if status["not_staged"]:
  4630. logger.info("\nModified LFS files not staged:")
  4631. for file_path in status["not_staged"]:
  4632. logger.warning(" %s", to_display_str(file_path))
  4633. if not any(status.values()):
  4634. logger.info("No LFS files found.")
  4635. else:
  4636. parser.print_help()
  4637. sys.exit(1)
  4638. class cmd_help(Command):
  4639. """Display help information about git."""
  4640. def run(self, args: Sequence[str]) -> None:
  4641. """Execute the help command.
  4642. Args:
  4643. args: Command line arguments
  4644. """
  4645. parser = argparse.ArgumentParser()
  4646. parser.add_argument(
  4647. "-a",
  4648. "--all",
  4649. action="store_true",
  4650. help="List all commands.",
  4651. )
  4652. parsed_args = parser.parse_args(args)
  4653. if parsed_args.all:
  4654. logger.info("Available commands:")
  4655. for cmd in sorted(commands):
  4656. logger.info(" %s", cmd)
  4657. else:
  4658. logger.info(
  4659. "The dulwich command line tool is currently a very basic frontend for the\n"
  4660. "Dulwich python module. For full functionality, please see the API reference.\n"
  4661. "\n"
  4662. "For a list of supported commands, see 'dulwich help -a'."
  4663. )
  4664. class cmd_format_patch(Command):
  4665. """Prepare patches for e-mail submission."""
  4666. def run(self, args: Sequence[str]) -> None:
  4667. """Execute the format-patch command.
  4668. Args:
  4669. args: Command line arguments
  4670. """
  4671. parser = argparse.ArgumentParser()
  4672. parser.add_argument(
  4673. "committish",
  4674. nargs="?",
  4675. help="Commit or commit range (e.g., HEAD~3..HEAD or origin/master..HEAD)",
  4676. )
  4677. parser.add_argument(
  4678. "-n",
  4679. "--numbered",
  4680. type=int,
  4681. default=1,
  4682. help="Number of commits to format (default: 1)",
  4683. )
  4684. parser.add_argument(
  4685. "-o",
  4686. "--output-directory",
  4687. dest="outdir",
  4688. help="Output directory for patches",
  4689. )
  4690. parser.add_argument(
  4691. "--stdout",
  4692. action="store_true",
  4693. help="Output patches to stdout",
  4694. )
  4695. parsed_args = parser.parse_args(args)
  4696. # Parse committish using the new function
  4697. committish: bytes | tuple[bytes, bytes] | None = None
  4698. if parsed_args.committish:
  4699. with Repo(".") as r:
  4700. range_result = parse_commit_range(r, parsed_args.committish)
  4701. if range_result:
  4702. # Convert Commit objects to their SHAs
  4703. committish = (range_result[0].id, range_result[1].id)
  4704. else:
  4705. committish = (
  4706. parsed_args.committish.encode()
  4707. if isinstance(parsed_args.committish, str)
  4708. else parsed_args.committish
  4709. )
  4710. filenames = porcelain.format_patch(
  4711. ".",
  4712. committish=committish,
  4713. outstream=sys.stdout,
  4714. outdir=parsed_args.outdir,
  4715. n=parsed_args.numbered,
  4716. stdout=parsed_args.stdout,
  4717. )
  4718. if not parsed_args.stdout:
  4719. for filename in filenames:
  4720. logger.info(filename)
  4721. class cmd_mailsplit(Command):
  4722. """Split mbox or Maildir into individual message files."""
  4723. def run(self, args: Sequence[str]) -> None:
  4724. """Execute the mailsplit command.
  4725. Args:
  4726. args: Command line arguments
  4727. """
  4728. parser = argparse.ArgumentParser()
  4729. parser.add_argument(
  4730. "mbox",
  4731. nargs="?",
  4732. help="Path to mbox file or Maildir. If not specified, reads from stdin.",
  4733. )
  4734. parser.add_argument(
  4735. "-o",
  4736. "--output-directory",
  4737. dest="output_dir",
  4738. required=True,
  4739. help="Directory in which to place the individual messages",
  4740. )
  4741. parser.add_argument(
  4742. "-b",
  4743. action="store_true",
  4744. dest="single_mail",
  4745. help="If any file doesn't begin with a From line, assume it is a single mail message",
  4746. )
  4747. parser.add_argument(
  4748. "-d",
  4749. dest="precision",
  4750. type=int,
  4751. default=4,
  4752. help="Number of digits for generated filenames (default: 4)",
  4753. )
  4754. parser.add_argument(
  4755. "-f",
  4756. dest="start_number",
  4757. type=int,
  4758. default=1,
  4759. help="Skip the first <nn> numbers (default: 1)",
  4760. )
  4761. parser.add_argument(
  4762. "--keep-cr",
  4763. action="store_true",
  4764. help="Do not remove \\r from lines ending with \\r\\n",
  4765. )
  4766. parser.add_argument(
  4767. "--mboxrd",
  4768. action="store_true",
  4769. help='Input is of the "mboxrd" format and "^>+From " line escaping is reversed',
  4770. )
  4771. parsed_args = parser.parse_args(args)
  4772. # Determine if input is a Maildir
  4773. is_maildir = False
  4774. if parsed_args.mbox:
  4775. input_path = Path(parsed_args.mbox)
  4776. if input_path.is_dir():
  4777. # Check if it's a Maildir (has cur, tmp, new subdirectories)
  4778. if (
  4779. (input_path / "cur").exists()
  4780. and (input_path / "tmp").exists()
  4781. and (input_path / "new").exists()
  4782. ):
  4783. is_maildir = True
  4784. else:
  4785. input_path = None
  4786. # Call porcelain function
  4787. output_files = porcelain.mailsplit(
  4788. input_path=input_path,
  4789. output_dir=parsed_args.output_dir,
  4790. start_number=parsed_args.start_number,
  4791. precision=parsed_args.precision,
  4792. keep_cr=parsed_args.keep_cr,
  4793. mboxrd=parsed_args.mboxrd,
  4794. is_maildir=is_maildir,
  4795. )
  4796. # Print information about the split
  4797. logger.info(
  4798. "Split %d messages into %s", len(output_files), parsed_args.output_dir
  4799. )
  4800. class cmd_mailinfo(Command):
  4801. """Extract patch information from an email message."""
  4802. def run(self, args: Sequence[str]) -> None:
  4803. """Execute the mailinfo command.
  4804. Args:
  4805. args: Command line arguments
  4806. """
  4807. parser = argparse.ArgumentParser()
  4808. parser.add_argument(
  4809. "msg",
  4810. help="File to write commit message",
  4811. )
  4812. parser.add_argument(
  4813. "patch",
  4814. help="File to write patch content",
  4815. )
  4816. parser.add_argument(
  4817. "mail",
  4818. nargs="?",
  4819. help="Path to email file. If not specified, reads from stdin.",
  4820. )
  4821. parser.add_argument(
  4822. "-k",
  4823. action="store_true",
  4824. dest="keep_subject",
  4825. help="Pass -k flag to git mailinfo (keeps [PATCH] and other subject tags)",
  4826. )
  4827. parser.add_argument(
  4828. "-b",
  4829. action="store_true",
  4830. dest="keep_non_patch",
  4831. help="Pass -b flag to git mailinfo (only strip [PATCH] tags)",
  4832. )
  4833. parser.add_argument(
  4834. "--encoding",
  4835. dest="encoding",
  4836. help="Character encoding to use (default: detect from message)",
  4837. )
  4838. parser.add_argument(
  4839. "--scissors",
  4840. action="store_true",
  4841. help="Remove everything before scissors line",
  4842. )
  4843. parser.add_argument(
  4844. "-m",
  4845. "--message-id",
  4846. action="store_true",
  4847. dest="message_id",
  4848. help="Copy Message-ID to the end of the commit message",
  4849. )
  4850. parsed_args = parser.parse_args(args)
  4851. # Call porcelain function
  4852. result = porcelain.mailinfo(
  4853. input_path=parsed_args.mail,
  4854. msg_file=parsed_args.msg,
  4855. patch_file=parsed_args.patch,
  4856. keep_subject=parsed_args.keep_subject,
  4857. keep_non_patch=parsed_args.keep_non_patch,
  4858. encoding=parsed_args.encoding,
  4859. scissors=parsed_args.scissors,
  4860. message_id=parsed_args.message_id,
  4861. )
  4862. # Print author info to stdout (as git mailinfo does)
  4863. print(f"Author: {result.author_name}")
  4864. print(f"Email: {result.author_email}")
  4865. print(f"Subject: {result.subject}")
  4866. if result.author_date:
  4867. print(f"Date: {result.author_date}")
  4868. class cmd_bundle(Command):
  4869. """Create, unpack, and manipulate bundle files."""
  4870. def run(self, args: Sequence[str]) -> int:
  4871. """Execute the bundle command.
  4872. Args:
  4873. args: Command line arguments
  4874. """
  4875. if not args:
  4876. logger.error("Usage: bundle <create|verify|list-heads|unbundle> <options>")
  4877. return 1
  4878. subcommand = args[0]
  4879. subargs = args[1:]
  4880. if subcommand == "create":
  4881. return self._create(subargs)
  4882. elif subcommand == "verify":
  4883. return self._verify(subargs)
  4884. elif subcommand == "list-heads":
  4885. return self._list_heads(subargs)
  4886. elif subcommand == "unbundle":
  4887. return self._unbundle(subargs)
  4888. else:
  4889. logger.error("Unknown bundle subcommand: %s", subcommand)
  4890. return 1
  4891. def _create(self, args: Sequence[str]) -> int:
  4892. parser = argparse.ArgumentParser(prog="bundle create")
  4893. parser.add_argument(
  4894. "-q", "--quiet", action="store_true", help="Suppress progress"
  4895. )
  4896. parser.add_argument("--progress", action="store_true", help="Show progress")
  4897. parser.add_argument(
  4898. "--version", type=int, choices=[2, 3], help="Bundle version"
  4899. )
  4900. parser.add_argument("--all", action="store_true", help="Include all refs")
  4901. parser.add_argument("--stdin", action="store_true", help="Read refs from stdin")
  4902. parser.add_argument("file", help="Output bundle file (use - for stdout)")
  4903. parser.add_argument("refs", nargs="*", help="References or rev-list args")
  4904. parsed_args = parser.parse_args(args)
  4905. repo = Repo(".")
  4906. progress = None
  4907. if parsed_args.progress and not parsed_args.quiet:
  4908. def progress(*args: str | int) -> None:
  4909. # Handle both progress(msg) and progress(count, msg) signatures
  4910. if len(args) == 1:
  4911. msg = args[0]
  4912. elif len(args) == 2:
  4913. _count, msg = args
  4914. else:
  4915. msg = str(args)
  4916. # Convert bytes to string if needed
  4917. if isinstance(msg, bytes):
  4918. msg = msg.decode("utf-8", "replace")
  4919. logger.error("%s", msg)
  4920. refs_to_include = []
  4921. prerequisites = []
  4922. if parsed_args.all:
  4923. refs_to_include = list(repo.refs.keys())
  4924. elif parsed_args.stdin:
  4925. for line in sys.stdin:
  4926. ref = line.strip().encode("utf-8")
  4927. if ref:
  4928. refs_to_include.append(ref)
  4929. elif parsed_args.refs:
  4930. for ref_arg in parsed_args.refs:
  4931. if ".." in ref_arg:
  4932. range_result = parse_commit_range(repo, ref_arg)
  4933. if range_result:
  4934. start_commit, _end_commit = range_result
  4935. prerequisites.append(start_commit.id)
  4936. # For ranges like A..B, we need to include B if it's a ref
  4937. # Split the range to get the end part
  4938. end_part = ref_arg.split("..")[1]
  4939. if end_part: # Not empty (not "A..")
  4940. end_ref = end_part.encode("utf-8")
  4941. if end_ref in repo.refs:
  4942. refs_to_include.append(end_ref)
  4943. else:
  4944. sha = repo.refs[ref_arg.encode("utf-8")]
  4945. refs_to_include.append(ref_arg.encode("utf-8"))
  4946. else:
  4947. if ref_arg.startswith("^"):
  4948. sha = repo.refs[ref_arg[1:].encode("utf-8")]
  4949. prerequisites.append(sha)
  4950. else:
  4951. sha = repo.refs[ref_arg.encode("utf-8")]
  4952. refs_to_include.append(ref_arg.encode("utf-8"))
  4953. else:
  4954. logger.error("No refs specified. Use --all, --stdin, or specify refs")
  4955. return 1
  4956. if not refs_to_include:
  4957. logger.error("fatal: Refusing to create empty bundle.")
  4958. return 1
  4959. bundle = create_bundle_from_repo(
  4960. repo,
  4961. refs=refs_to_include,
  4962. prerequisites=prerequisites,
  4963. version=parsed_args.version,
  4964. progress=progress,
  4965. )
  4966. if parsed_args.file == "-":
  4967. write_bundle(sys.stdout.buffer, bundle)
  4968. else:
  4969. with open(parsed_args.file, "wb") as f:
  4970. write_bundle(f, bundle)
  4971. return 0
  4972. def _verify(self, args: Sequence[str]) -> int:
  4973. parser = argparse.ArgumentParser(prog="bundle verify")
  4974. parser.add_argument(
  4975. "-q", "--quiet", action="store_true", help="Suppress output"
  4976. )
  4977. parser.add_argument("file", help="Bundle file to verify (use - for stdin)")
  4978. parsed_args = parser.parse_args(args)
  4979. repo = Repo(".")
  4980. def verify_bundle(bundle: Bundle) -> int:
  4981. missing_prereqs = []
  4982. for prereq_sha, comment in bundle.prerequisites:
  4983. try:
  4984. repo.object_store[prereq_sha]
  4985. except KeyError:
  4986. missing_prereqs.append(prereq_sha)
  4987. if missing_prereqs:
  4988. if not parsed_args.quiet:
  4989. logger.info("The bundle requires these prerequisite commits:")
  4990. for sha in missing_prereqs:
  4991. logger.info(" %s", sha.decode())
  4992. return 1
  4993. else:
  4994. if not parsed_args.quiet:
  4995. logger.info(
  4996. "The bundle is valid and can be applied to the current repository"
  4997. )
  4998. return 0
  4999. if parsed_args.file == "-":
  5000. bundle = read_bundle(sys.stdin.buffer)
  5001. return verify_bundle(bundle)
  5002. else:
  5003. with open(parsed_args.file, "rb") as f:
  5004. bundle = read_bundle(f)
  5005. return verify_bundle(bundle)
  5006. def _list_heads(self, args: Sequence[str]) -> int:
  5007. parser = argparse.ArgumentParser(prog="bundle list-heads")
  5008. parser.add_argument("file", help="Bundle file (use - for stdin)")
  5009. parser.add_argument("refnames", nargs="*", help="Only show these refs")
  5010. parsed_args = parser.parse_args(args)
  5011. def list_heads(bundle: Bundle) -> None:
  5012. for ref, sha in bundle.references.items():
  5013. if not parsed_args.refnames or ref.decode() in parsed_args.refnames:
  5014. logger.info("%s %s", sha.decode(), ref.decode())
  5015. if parsed_args.file == "-":
  5016. bundle = read_bundle(sys.stdin.buffer)
  5017. list_heads(bundle)
  5018. else:
  5019. with open(parsed_args.file, "rb") as f:
  5020. bundle = read_bundle(f)
  5021. list_heads(bundle)
  5022. return 0
  5023. def _unbundle(self, args: Sequence[str]) -> int:
  5024. parser = argparse.ArgumentParser(prog="bundle unbundle")
  5025. parser.add_argument("--progress", action="store_true", help="Show progress")
  5026. parser.add_argument("file", help="Bundle file (use - for stdin)")
  5027. parser.add_argument("refnames", nargs="*", help="Only unbundle these refs")
  5028. parsed_args = parser.parse_args(args)
  5029. repo = Repo(".")
  5030. progress = None
  5031. if parsed_args.progress:
  5032. def progress(*args: str | int | bytes) -> None:
  5033. # Handle both progress(msg) and progress(count, msg) signatures
  5034. if len(args) == 1:
  5035. msg = args[0]
  5036. elif len(args) == 2:
  5037. _count, msg = args
  5038. else:
  5039. msg = str(args)
  5040. # Convert bytes to string if needed
  5041. if isinstance(msg, bytes):
  5042. msg = msg.decode("utf-8", "replace")
  5043. elif not isinstance(msg, str):
  5044. msg = str(msg)
  5045. logger.error("%s", msg)
  5046. if parsed_args.file == "-":
  5047. bundle = read_bundle(sys.stdin.buffer)
  5048. # Process the bundle while file is still available via stdin
  5049. bundle.store_objects(repo.object_store, progress=progress)
  5050. else:
  5051. # Keep the file open during bundle processing
  5052. with open(parsed_args.file, "rb") as f:
  5053. bundle = read_bundle(f)
  5054. # Process pack data while file is still open
  5055. bundle.store_objects(repo.object_store, progress=progress)
  5056. for ref, sha in bundle.references.items():
  5057. if not parsed_args.refnames or ref.decode() in parsed_args.refnames:
  5058. logger.info(ref.decode())
  5059. return 0
  5060. class cmd_worktree_add(Command):
  5061. """Create a new worktree."""
  5062. """Add a new worktree to the repository."""
  5063. def run(self, args: Sequence[str]) -> int | None:
  5064. """Execute the worktree-add command.
  5065. Args:
  5066. args: Command line arguments
  5067. """
  5068. parser = argparse.ArgumentParser(
  5069. description="Add a new worktree", prog="dulwich worktree add"
  5070. )
  5071. parser.add_argument("path", help="Path for the new worktree")
  5072. parser.add_argument("committish", nargs="?", help="Commit-ish to checkout")
  5073. parser.add_argument("-b", "--create-branch", help="Create a new branch")
  5074. parser.add_argument(
  5075. "-B", "--force-create-branch", help="Create or reset a branch"
  5076. )
  5077. parser.add_argument(
  5078. "--detach", action="store_true", help="Detach HEAD in new worktree"
  5079. )
  5080. parser.add_argument("--force", action="store_true", help="Force creation")
  5081. parsed_args = parser.parse_args(args)
  5082. from dulwich import porcelain
  5083. branch = None
  5084. commit = None
  5085. if parsed_args.create_branch or parsed_args.force_create_branch:
  5086. branch = (
  5087. parsed_args.create_branch or parsed_args.force_create_branch
  5088. ).encode()
  5089. elif parsed_args.committish and not parsed_args.detach:
  5090. # If committish is provided and not detaching, treat as branch
  5091. branch = parsed_args.committish.encode()
  5092. elif parsed_args.committish:
  5093. # If committish is provided and detaching, treat as commit
  5094. commit = parsed_args.committish.encode()
  5095. worktree_path = porcelain.worktree_add(
  5096. repo=".",
  5097. path=parsed_args.path,
  5098. branch=branch,
  5099. commit=commit,
  5100. detach=parsed_args.detach,
  5101. force=parsed_args.force or bool(parsed_args.force_create_branch),
  5102. )
  5103. logger.info("Worktree added: %s", worktree_path)
  5104. return 0
  5105. class cmd_worktree_list(Command):
  5106. """List worktrees."""
  5107. """List details of each worktree."""
  5108. def run(self, args: Sequence[str]) -> int | None:
  5109. """Execute the worktree-list command.
  5110. Args:
  5111. args: Command line arguments
  5112. """
  5113. parser = argparse.ArgumentParser(
  5114. description="List worktrees", prog="dulwich worktree list"
  5115. )
  5116. parser.add_argument(
  5117. "-v", "--verbose", action="store_true", help="Show additional information"
  5118. )
  5119. parser.add_argument(
  5120. "--porcelain", action="store_true", help="Machine-readable output"
  5121. )
  5122. parsed_args = parser.parse_args(args)
  5123. from dulwich import porcelain
  5124. worktrees = porcelain.worktree_list(repo=".")
  5125. for wt in worktrees:
  5126. path = wt.path
  5127. if wt.bare:
  5128. status = "(bare)"
  5129. elif wt.detached:
  5130. status = (
  5131. f"(detached HEAD {wt.head[:7].decode() if wt.head else 'unknown'})"
  5132. )
  5133. elif wt.branch:
  5134. branch_name = wt.branch.decode().replace("refs/heads/", "")
  5135. status = f"[{branch_name}]"
  5136. else:
  5137. status = "(unknown)"
  5138. if parsed_args.porcelain:
  5139. locked = "locked" if wt.locked else "unlocked"
  5140. prunable = "prunable" if wt.prunable else "unprunable"
  5141. logger.info(
  5142. "%s %s %s %s %s",
  5143. path,
  5144. wt.head.decode() if wt.head else "unknown",
  5145. status,
  5146. locked,
  5147. prunable,
  5148. )
  5149. else:
  5150. line = f"{path} {status}"
  5151. if wt.locked:
  5152. line += " locked"
  5153. if wt.prunable:
  5154. line += " prunable"
  5155. logger.info(line)
  5156. return 0
  5157. class cmd_worktree_remove(Command):
  5158. """Remove a worktree."""
  5159. """Remove a worktree."""
  5160. def run(self, args: Sequence[str]) -> int | None:
  5161. """Execute the worktree-remove command.
  5162. Args:
  5163. args: Command line arguments
  5164. """
  5165. parser = argparse.ArgumentParser(
  5166. description="Remove a worktree", prog="dulwich worktree remove"
  5167. )
  5168. parser.add_argument("worktree", help="Path to worktree to remove")
  5169. parser.add_argument("--force", action="store_true", help="Force removal")
  5170. parsed_args = parser.parse_args(args)
  5171. from dulwich import porcelain
  5172. porcelain.worktree_remove(
  5173. repo=".", path=parsed_args.worktree, force=parsed_args.force
  5174. )
  5175. logger.info("Worktree removed: %s", parsed_args.worktree)
  5176. return 0
  5177. class cmd_worktree_prune(Command):
  5178. """Prune worktree information."""
  5179. """Prune worktree information."""
  5180. def run(self, args: Sequence[str]) -> int | None:
  5181. """Execute the worktree-prune command.
  5182. Args:
  5183. args: Command line arguments
  5184. """
  5185. parser = argparse.ArgumentParser(
  5186. description="Prune worktree information", prog="dulwich worktree prune"
  5187. )
  5188. parser.add_argument(
  5189. "--dry-run", action="store_true", help="Do not remove anything"
  5190. )
  5191. parser.add_argument(
  5192. "-v", "--verbose", action="store_true", help="Report all removals"
  5193. )
  5194. parser.add_argument(
  5195. "--expire", type=int, help="Expire worktrees older than time (seconds)"
  5196. )
  5197. parsed_args = parser.parse_args(args)
  5198. from dulwich import porcelain
  5199. pruned = porcelain.worktree_prune(
  5200. repo=".", dry_run=parsed_args.dry_run, expire=parsed_args.expire
  5201. )
  5202. if pruned:
  5203. if parsed_args.dry_run:
  5204. logger.info("Would prune worktrees:")
  5205. elif parsed_args.verbose:
  5206. logger.info("Pruned worktrees:")
  5207. for wt_id in pruned:
  5208. logger.info(" %s", wt_id)
  5209. elif parsed_args.verbose:
  5210. logger.info("No worktrees to prune")
  5211. return 0
  5212. class cmd_worktree_lock(Command):
  5213. """Lock a worktree to prevent it from being pruned."""
  5214. """Lock a worktree."""
  5215. def run(self, args: Sequence[str]) -> int | None:
  5216. """Execute the worktree-lock command.
  5217. Args:
  5218. args: Command line arguments
  5219. """
  5220. parser = argparse.ArgumentParser(
  5221. description="Lock a worktree", prog="dulwich worktree lock"
  5222. )
  5223. parser.add_argument("worktree", help="Path to worktree to lock")
  5224. parser.add_argument("--reason", help="Reason for locking")
  5225. parsed_args = parser.parse_args(args)
  5226. from dulwich import porcelain
  5227. porcelain.worktree_lock(
  5228. repo=".", path=parsed_args.worktree, reason=parsed_args.reason
  5229. )
  5230. logger.info("Worktree locked: %s", parsed_args.worktree)
  5231. return 0
  5232. class cmd_worktree_unlock(Command):
  5233. """Unlock a locked worktree."""
  5234. """Unlock a worktree."""
  5235. def run(self, args: Sequence[str]) -> int | None:
  5236. """Execute the worktree-unlock command.
  5237. Args:
  5238. args: Command line arguments
  5239. """
  5240. parser = argparse.ArgumentParser(
  5241. description="Unlock a worktree", prog="dulwich worktree unlock"
  5242. )
  5243. parser.add_argument("worktree", help="Path to worktree to unlock")
  5244. parsed_args = parser.parse_args(args)
  5245. from dulwich import porcelain
  5246. porcelain.worktree_unlock(repo=".", path=parsed_args.worktree)
  5247. logger.info("Worktree unlocked: %s", parsed_args.worktree)
  5248. return 0
  5249. class cmd_worktree_move(Command):
  5250. """Move a worktree to a new location."""
  5251. """Move a worktree."""
  5252. def run(self, args: Sequence[str]) -> int | None:
  5253. """Execute the worktree-move command.
  5254. Args:
  5255. args: Command line arguments
  5256. """
  5257. parser = argparse.ArgumentParser(
  5258. description="Move a worktree", prog="dulwich worktree move"
  5259. )
  5260. parser.add_argument("worktree", help="Path to worktree to move")
  5261. parser.add_argument("new_path", help="New path for the worktree")
  5262. parsed_args = parser.parse_args(args)
  5263. from dulwich import porcelain
  5264. porcelain.worktree_move(
  5265. repo=".", old_path=parsed_args.worktree, new_path=parsed_args.new_path
  5266. )
  5267. logger.info(
  5268. "Worktree moved: %s -> %s", parsed_args.worktree, parsed_args.new_path
  5269. )
  5270. return 0
  5271. class cmd_worktree_repair(Command):
  5272. """Repair worktree administrative files."""
  5273. """Repair worktree administrative files."""
  5274. def run(self, args: Sequence[str]) -> int | None:
  5275. """Execute the worktree-repair command.
  5276. Args:
  5277. args: Command line arguments
  5278. """
  5279. parser = argparse.ArgumentParser(
  5280. description="Repair worktree administrative files",
  5281. prog="dulwich worktree repair",
  5282. )
  5283. parser.add_argument(
  5284. "path",
  5285. nargs="*",
  5286. help="Paths to worktrees to repair (if not specified, repairs all)",
  5287. )
  5288. parsed_args = parser.parse_args(args)
  5289. from dulwich import porcelain
  5290. paths = parsed_args.path if parsed_args.path else None
  5291. repaired = porcelain.worktree_repair(repo=".", paths=paths)
  5292. if repaired:
  5293. for path in repaired:
  5294. logger.info("Repaired worktree: %s", path)
  5295. else:
  5296. logger.info("No worktrees needed repair")
  5297. return 0
  5298. class cmd_worktree(SuperCommand):
  5299. """Manage multiple working trees."""
  5300. """Manage multiple working trees."""
  5301. subcommands: ClassVar[dict[str, type[Command]]] = {
  5302. "add": cmd_worktree_add,
  5303. "list": cmd_worktree_list,
  5304. "remove": cmd_worktree_remove,
  5305. "prune": cmd_worktree_prune,
  5306. "lock": cmd_worktree_lock,
  5307. "unlock": cmd_worktree_unlock,
  5308. "move": cmd_worktree_move,
  5309. "repair": cmd_worktree_repair,
  5310. }
  5311. default_command = cmd_worktree_list
  5312. commands = {
  5313. "add": cmd_add,
  5314. "annotate": cmd_annotate,
  5315. "archive": cmd_archive,
  5316. "bisect": cmd_bisect,
  5317. "blame": cmd_blame,
  5318. "branch": cmd_branch,
  5319. "bundle": cmd_bundle,
  5320. "check-ignore": cmd_check_ignore,
  5321. "check-mailmap": cmd_check_mailmap,
  5322. "checkout": cmd_checkout,
  5323. "cherry": cmd_cherry,
  5324. "cherry-pick": cmd_cherry_pick,
  5325. "clone": cmd_clone,
  5326. "column": cmd_column,
  5327. "commit": cmd_commit,
  5328. "commit-tree": cmd_commit_tree,
  5329. "config": cmd_config,
  5330. "count-objects": cmd_count_objects,
  5331. "describe": cmd_describe,
  5332. "daemon": cmd_daemon,
  5333. "diff": cmd_diff,
  5334. "diff-tree": cmd_diff_tree,
  5335. "dump-pack": cmd_dump_pack,
  5336. "dump-index": cmd_dump_index,
  5337. "fetch-pack": cmd_fetch_pack,
  5338. "fetch": cmd_fetch,
  5339. "filter-branch": cmd_filter_branch,
  5340. "for-each-ref": cmd_for_each_ref,
  5341. "format-patch": cmd_format_patch,
  5342. "fsck": cmd_fsck,
  5343. "gc": cmd_gc,
  5344. "grep": cmd_grep,
  5345. "help": cmd_help,
  5346. "init": cmd_init,
  5347. "interpret-trailers": cmd_interpret_trailers,
  5348. "lfs": cmd_lfs,
  5349. "log": cmd_log,
  5350. "ls-files": cmd_ls_files,
  5351. "ls-remote": cmd_ls_remote,
  5352. "ls-tree": cmd_ls_tree,
  5353. "maintenance": cmd_maintenance,
  5354. "mailinfo": cmd_mailinfo,
  5355. "mailsplit": cmd_mailsplit,
  5356. "merge": cmd_merge,
  5357. "merge-base": cmd_merge_base,
  5358. "merge-tree": cmd_merge_tree,
  5359. "notes": cmd_notes,
  5360. "pack-objects": cmd_pack_objects,
  5361. "pack-refs": cmd_pack_refs,
  5362. "prune": cmd_prune,
  5363. "pull": cmd_pull,
  5364. "push": cmd_push,
  5365. "rebase": cmd_rebase,
  5366. "receive-pack": cmd_receive_pack,
  5367. "reflog": cmd_reflog,
  5368. "remote": cmd_remote,
  5369. "repack": cmd_repack,
  5370. "replace": cmd_replace,
  5371. "reset": cmd_reset,
  5372. "revert": cmd_revert,
  5373. "rev-list": cmd_rev_list,
  5374. "rm": cmd_rm,
  5375. "mv": cmd_mv,
  5376. "show": cmd_show,
  5377. "show-branch": cmd_show_branch,
  5378. "show-ref": cmd_show_ref,
  5379. "stash": cmd_stash,
  5380. "status": cmd_status,
  5381. "stripspace": cmd_stripspace,
  5382. "shortlog": cmd_shortlog,
  5383. "symbolic-ref": cmd_symbolic_ref,
  5384. "submodule": cmd_submodule,
  5385. "tag": cmd_tag,
  5386. "unpack-objects": cmd_unpack_objects,
  5387. "update-server-info": cmd_update_server_info,
  5388. "upload-pack": cmd_upload_pack,
  5389. "var": cmd_var,
  5390. "verify-commit": cmd_verify_commit,
  5391. "verify-tag": cmd_verify_tag,
  5392. "web-daemon": cmd_web_daemon,
  5393. "worktree": cmd_worktree,
  5394. "write-tree": cmd_write_tree,
  5395. }
  5396. def main(argv: Sequence[str] | None = None) -> int | None:
  5397. """Main entry point for the Dulwich CLI.
  5398. Args:
  5399. argv: Command line arguments (defaults to sys.argv[1:])
  5400. Returns:
  5401. Exit code or None
  5402. """
  5403. # Wrap stdout and stderr to respect GIT_FLUSH environment variable
  5404. sys.stdout = AutoFlushTextIOWrapper.env(sys.stdout)
  5405. sys.stderr = AutoFlushTextIOWrapper.env(sys.stderr)
  5406. if argv is None:
  5407. argv = sys.argv[1:]
  5408. # Parse only the global options and command, stop at first positional
  5409. parser = argparse.ArgumentParser(
  5410. prog="dulwich",
  5411. description="Simple command-line interface to Dulwich",
  5412. add_help=False, # We'll handle help ourselves
  5413. )
  5414. parser.add_argument("--no-pager", action="store_true", help="Disable pager")
  5415. parser.add_argument("--pager", action="store_true", help="Force enable pager")
  5416. parser.add_argument("--help", "-h", action="store_true", help="Show help")
  5417. # Parse known args to separate global options from command args
  5418. global_args, remaining = parser.parse_known_args(argv)
  5419. # Apply global pager settings
  5420. if global_args.no_pager:
  5421. disable_pager()
  5422. elif global_args.pager:
  5423. enable_pager()
  5424. # Handle help
  5425. if global_args.help or not remaining:
  5426. parser = argparse.ArgumentParser(
  5427. prog="dulwich", description="Simple command-line interface to Dulwich"
  5428. )
  5429. parser.add_argument("--no-pager", action="store_true", help="Disable pager")
  5430. parser.add_argument("--pager", action="store_true", help="Force enable pager")
  5431. parser.add_argument(
  5432. "command",
  5433. nargs="?",
  5434. help=f"Command to run. Available: {', '.join(sorted(commands.keys()))}",
  5435. )
  5436. parser.print_help()
  5437. return 1
  5438. # Try to configure from GIT_TRACE, fall back to default if it fails
  5439. if not _configure_logging_from_trace():
  5440. logging.basicConfig(
  5441. level=logging.INFO,
  5442. format="%(message)s",
  5443. )
  5444. # First remaining arg is the command
  5445. cmd = remaining[0]
  5446. cmd_args = remaining[1:]
  5447. try:
  5448. cmd_kls = commands[cmd]
  5449. except KeyError:
  5450. logging.fatal("No such subcommand: %s", cmd)
  5451. return 1
  5452. # TODO(jelmer): Return non-0 on errors
  5453. return cmd_kls().run(cmd_args)
  5454. def _main() -> None:
  5455. if "DULWICH_PDB" in os.environ and getattr(signal, "SIGQUIT", None):
  5456. signal.signal(signal.SIGQUIT, signal_quit) # type: ignore[attr-defined,unused-ignore]
  5457. signal.signal(signal.SIGINT, signal_int)
  5458. sys.exit(main())
  5459. if __name__ == "__main__":
  5460. _main()