Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394//! Creating a stack: `stack create`.//!//! The write half of [`crate::cmd::stack`], on the `pr` write half's terms: it//! settles which account it acts as before reading anything, announces it,//! and honours `--dry-run`. What it writes is one `sh.tangled.repo.pull`//! record per *member* of `base..HEAD` — the runs the local branches//! pointing into the range cut it into, or one commit each when nothing//! points into it — bottom first, each `dependentOn` the record before it, in a single `com.atproto.repo.applyWrites` — atomic,//! so no partial stack can exist on the wire, and record keys are minted//! here (monotonic TIDs) so every record can name its parent's at-uri//! before anything is sent. This is the same shape Tangled's own web//! compose writes.//!//! The change-id story is the part worth reading twice. A stacked round's//! patch must carry a `Change-Id:` **mail header** — that is what the//! appview correlates by — and `git format-patch` emits no such header, not//! even for jj commits whose change-id lives in the commit object (checked//! empirically; git knows nothing of that header). So the id is read off//! the commit ([`crate::clients::git::patch::change_id`]: jj header first, trailer second)//! and injected into the patch text by [`with_change_id_header`]. Commits//! with no id at all are refused, with `--add-change-ids` offering the//! rewrite ([`crate::clients::git::patch::rewrite_with_change_ids`]) that adds trailers.//!//! **The branch is published first, and that is what makes `source` true.**//! Every member carries `source: {branch}`, which is not a hint: the appview//! tells the three shapes of pull apart by that field alone and never checks//! it (`appview/models/pull.go`, `IsPatchBased`/`IsBranchBased`). Writing it//! for a branch nobody pushed is the untruth `pr create` stopped telling in//! PR #282, and a stack cannot buy its way out the way a flat pull did://! `sh.tangled.repo.compare` answers about a *range*, and a member's patch is//! one commit's, so the bytes still have to be formatted here. So the push is//! kept and the compare is demoted to a proof — the knot is asked what it has//! between the target and the branch, and a knot that has nothing refuses the//! create exactly as `handleBranchBasedPull` does.//!//! Dropping `source` instead was the other option, and it is not open. Three//! separate things read it: `resubmitCheck` in `appview/pulls/single.go`//! compares the *top* member's sha against the live branch head, the web's//! resubmit routes a branch-based member through `repo.compare` and re-splits//! the range by change-id (`appview/pulls/resubmit.go`), and//! `appview/pulls/compose.go` will not compose a stacked pull that is//! patch-based at all (`isStacked := mode == "stack" && !isPatchBased`).//! atgc's own reads enter the same way — [`crate::cmd::pr::read::for_branch`]//! matches on `source.branch`, which is how `stack view`, `stack resubmit`//! and `stack merge` find a chain — so a sourceless stack would be one no//! command here could ever pick up again. There is therefore no//! `--patch-only` for a stack: a target that cannot be pushed to gets a//! refusal, not a shape nothing can read.//!//! One thing the push does *not* buy, said here because the knot's source//! settles it: `knotserver/git/diff.go` adds the `Change-Id:` header to its//! format-patch only from a commit object's jj `change-id` extra header, and//! never from a `Change-Id:` trailer in the message. atgc reads either//! (trailers are what `--add-change-ids` writes), so a trailer-only branch//! makes a stack whose *web* resubmit refuses for want of change-ids. The//! compare below notices and says so; `atgc stack resubmit` is unaffected,//! because it formats and injects the headers itself.
use crate::clients::git::patch as gitpatch;use anyhow::{Context, Result, bail};use jacquard::types::string::{AtUri, Datetime};use jacquard::types::tid::Ticker;use std::path::Path;
use crate::clients::atproto::record::{Key, Op, gzip, upload_patch_blob};use crate::clients::git::run as git;use crate::clients::tangled::resolve;use crate::cmd::auth;use crate::cmd::pr::read as listing;use crate::lexicon::tangled::PULL_NSID;use tangled_lexicon::LexiconSchema;use tangled_lexicon::sh_tangled::repo::pull::{Pull, Round, Source};
#[derive(clap::Args, Debug)]pub(crate) struct CreateArgs { /// Target branch on the destination repo (defaults to the remote's default branch) #[arg(long)] pub target: Option<String>, /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// Rewrite base..HEAD to add Change-Id trailers to commits lacking one #[arg(long)] pub add_change_ids: bool, /// Ignore the branches pointing into the range and open one pull /// request per commit #[arg(long)] pub per_commit: bool, /// Describe the stack without rewriting or sending anything #[arg(long)] pub dry_run: bool, /// Print one JSON object describing what was written (or, with /// --dry-run, what would be) instead of the summary lines #[arg(long)] pub json: bool,}
/// One member of `stack create --json`.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct CreatedMemberJson { /// 1-based from the bottom, the order these are written and merged in — /// and, unlike `stack view --json`, the order this array is in too, /// because a create is a plan for a series of commits rather than a /// picture of a chain. pub position: usize, /// The full commit sha this member was planned from — its bottom /// commit, when it carries several. It does not survive /// `--add-change-ids`, which rewrites the branch; the change-id does, /// which is the whole reason both are here. pub sha: String, /// Every commit this member carries, bottom first. One entry, equal to /// `sha`, for the ordinary member; a branch mark is what makes it longer. pub shas: Vec<String>, /// `shas.len()`, spelled out so a caller counting members' commits does /// not have to. pub commits: usize, /// The member's identity across rewrites: its bottom commit's change-id. pub change_id: String, /// True for the branch's existing pull request, taken as this stack's /// bottom member rather than minted. Its `uri` is filled in on a dry run /// too, because unlike every other member it is already a record. pub adopted: bool, /// Every change-id this member carries, parallel to `shas`. pub change_ids: Vec<String>, /// The local branch whose tip ends this member, when one does. `null` /// for the top member — which ends at HEAD, on the branch being stacked /// — and for every member of a stack cut one commit at a time. pub branch: Option<String>, pub title: String, pub patch_bytes: usize, pub patch_gzip_bytes: usize, /// The pull record written for this commit, `null` on a dry run. pub uri: Option<String>, pub images: Vec<crate::cmd::images::ImageJson>,}
/// What `stack create` did, or would have done.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct StackCreatedJson { pub dry_run: bool, pub repo_did: String, pub target_branch: String, pub source_branch: String, pub total: usize, /// Bottom first, as they are written and chained. pub members: Vec<CreatedMemberJson>, /// True on a dry run that would have rewritten the branch to add /// Change-Id trailers — in which case every `sha` above, and every /// patch size, is what the rewrite would produce rather than what is on /// the branch now. A real run has already rewritten by this point, so it /// is false there. pub rewrite_pending: bool, /// Whether the branch was pushed to `remote` before the records were /// written. The `source` every member carries is only a true claim /// because of this, so it is reported next to them. False on a dry run, /// which publishes nothing. pub pushed: bool, pub url: String,}
/// One member's worth of stack, planned before anything is uploaded.////// A member is a *run* of commits, not a commit: `shas` and `change_ids` are/// parallel, bottom first, and hold one entry each for the ordinary/// one-commit member. The bottom commit is the member's identity ([`Planned::change_id`])/// and the source of its title and body; every commit in the run contributes/// a message to the patch, carrying its own `Change-Id:` header, which is/// what lets a reconcile still recognize the member after any of them is/// rewritten.struct Planned { /// Bottom first, never empty. shas: Vec<String>, /// Parallel to `shas`. change_ids: Vec<String>, subject: String, body: Option<String>, /// The member's patch: one message per commit, each with its own /// `Change-Id:` header injected. patch: String, /// Local images the commit message embeds — or the scan's refusal, /// deferred. `stack create` publishes every commit, so it fails fast on /// any error; the reconcile plans over *all* commits before fates are /// known, and a Keep member whose image left the disk (deleted by a /// later commit, or never committed at all) must not brick a resubmit /// that would never publish it. The error fires only when the member's /// fate actually uploads. images: Result<crate::cmd::images::Images>,}
impl Planned { /// The bottom commit: what the member is named, sized and reported by. fn sha(&self) -> &str { &self.shas[0] }
/// The member's identity across rewrites: its bottom commit's change-id. /// A member that gains or loses commits above this one is still the same /// member, which is what makes regrouping a branch survivable. fn change_id(&self) -> &str { &self.change_ids[0] }
/// The bottom sha, cut to the width every listing prints shas at. fn short(&self) -> &str { &self.sha()[..7.min(self.sha().len())] }
/// How many commits this member carries. `1` for the ordinary member, /// and the reason several of the listings below have a plural form. fn commits(&self) -> usize { self.shas.len() }}
/// Scan a member's body for local images, with the commit named when it/// refuses — "the body embeds …" alone would not say which message to fix.fn scan_member_body(sha: &str, body: Option<&str>) -> Result<crate::cmd::images::Images> { match body { Some(text) => crate::cmd::images::scan(text) .with_context(|| format!("in the message of commit {}", &sha[..7.min(sha.len())])), None => Ok(crate::cmd::images::Images::none()), }}
/// A member's body and patch in their published form: every local image/// destination already reads as the `blob+at://` URI its bytes will mint.////// Done at planning time, and both reasons fall out of prediction. The/// reconcile decides "changed" by comparing patch bytes, and a patch that/// only took its final text at upload time would compare different-forever/// against its own stored rounds — every resubmit an Update, every Update a/// spurious round. And the message travels with the patch into git history/// when the stack merges, where a machine-local path is a dead reference/// and a leak of the submitting machine's layout; the merged log should/// carry the same content-addressed URIs the record does.fn predict_member_rewrites( did: &str, body: Option<String>, patch: String, images: &Result<crate::cmd::images::Images>,) -> Result<(Option<String>, String)> { let Ok(images) = images else { // A deferred refusal: the member may never publish, and the bail // that names the commit fires in `upload_member_images` if it does. return Ok((body, patch)); }; if images.is_empty() { return Ok((body, patch)); } let body = match body { Some(text) => Some(images.rewrite_with_predictions(&text, did)?), None => None, }; // Only the message section — everything above the first scissors line — // is rewritten; the diff below it must never be touched. A commit // message containing its own `---` line cuts the rewrite short, which // misses a later image at worst and corrupts nothing. let patch = match patch.split_once("\n---\n") { Some((message, rest)) => format!( "{}\n---\n{rest}", crate::cmd::images::rewrite_message(message, &images.predicted_pairs(did)) ), None => patch, }; Ok((body, patch))}
/// A member's image blobs, uploaded and verified against the CIDs its body/// and patch already spell; `None` when the commit message has none.async fn upload_member_images( agent: &jacquard::client::Agent<crate::clients::atproto::oauth::Session>, p: &Planned,) -> Result<Option<Vec<jacquard::types::blob::Blob>>> { let images = match &p.images { Ok(images) => images, Err(e) => bail!( "commit {} is being published, and {e:#}\n\ restore the file, or amend the message to drop the reference", p.short(), ), }; if images.is_empty() { return Ok(None); } let blobs = crate::cmd::images::upload_predicted(agent, images).await?; Ok(crate::cmd::images::merge_blobs(None, blobs))}
/// Plan one member from a run of commits, ready to be sized, printed and/// uploaded.////// Shared by `create` and `resubmit` so the two cannot drift on what a/// member *is*: the same patch, the same headers, the same title and the/// same image handling, whether the run is one commit or five.////// `mint_missing` is `create --dry-run`'s allowance: a commit whose/// change-id the pending rewrite has not written yet is planned under the/// id that rewrite will mint for it, so the dry run can describe a stack/// the branch cannot yet produce. Everywhere else a missing id is a bug by/// this point — [`ensure_change_ids`] has already refused it — and says so.fn plan_group( did: &str, commits: &[String], group: &[usize], mint_missing: bool,) -> Result<Planned> { let shas: Vec<String> = group.iter().map(|&i| commits[i].clone()).collect(); let mut change_ids = Vec::with_capacity(shas.len()); for sha in &shas { match gitpatch::change_id(sha)? { Some(id) => change_ids.push(id), None if mint_missing => change_ids.push(format!("I{sha}")), None => bail!("commit {sha} still has no change-id"), } } let bottom = &shas[0]; let (subject, body) = subject_and_body(bottom)?; // The title and body come from the bottom commit even when the member // carries several: a member is one pull request, and the commit its // reviewer reads first is the one that names it. `atgc pr edit` is how // that gets a better title without rewriting git history for it. let raw = match shas.len() { 1 => gitpatch::format_patch_one(bottom)?, _ => gitpatch::format_patch_range(bottom, shas.last().expect("non-empty"))?, }; let patch = with_change_id_headers(&raw, &change_ids)?; let images = scan_member_body(bottom, body.as_deref()); let (body, patch) = predict_member_rewrites(did, body, patch, &images)?; Ok(Planned { shas, change_ids, subject, body, patch, images, })}
/// Plan every member of a cut branch, bottom first.fn plan_groups( did: &str, commits: &[String], groups: &Groups, mint_missing: bool,) -> Result<Vec<Planned>> { groups .iter() .map(|group| plan_group(did, commits, group, mint_missing)) .collect()}
/// **A change-id rewrite is undone when the command around it fails.**////// The rewrite is the only step in either of these commands that changes/// something outside the process before the process has finished deciding./// The rule that follows from that — settle everything able to refuse before/// mutating anything — was written down, fixed in `stack create` on/// 2026-08-15, and broken again in `stack resubmit` ten days later, because/// "everything able to refuse" is a list nobody keeps current and a comment/// cannot check. Two of this epic's open items were instances of it.////// So the rule is enforced instead of remembered: on any failure the branch/// goes back to where it was, and a refusal arriving after the rewrite costs/// nothing but time. [`gitpatch::Rewrite::undo`] is safe to do bluntly —/// `commit-tree` reuses every tree, so only messages and parentage differ —/// and declines when something else has moved the branch since, which is the/// one case where the old shas are not this command's to restore.////// Applied by wrapping the whole command rather than each fallible step,/// because "each fallible step" is the list that went stale twice.fn note_rewrite<T>(outcome: Result<T>, rewritten: Option<&gitpatch::Rewrite>) -> Result<T> { match (outcome, rewritten) { (Err(e), Some(rewrite)) => Err(match rewrite.undo() { Ok(true) => e.context(rewrite.restored()), // The branch moved under this run, so the shas on it now are not // this command's to discard. Naming the reflog is all that is // honestly on offer. Ok(false) => e.context(rewrite.recovery()), Err(undo) => { crate::logging::debug::dump_err("could not put the branch back", &undo); e.context(rewrite.recovery()) } }), (outcome, _) => outcome, }}
/// Split the current branch into a chain of dependent pull requests: one/// per commit, or the runs the branch marks cut. A branch that is one/// change from bottom to top belongs in a single pull request instead, via/// `pr create`.pub(crate) async fn create(args: CreateArgs) -> Result<()> { let mut rewritten = None; note_rewrite(create_inner(args, &mut rewritten).await, rewritten.as_ref())}
async fn create_inner(args: CreateArgs, rewritten: &mut Option<gitpatch::Rewrite>) -> Result<()> { crate::term::jsonout::init(args.json); let selection = crate::config::account::select().await?; selection.announce();
let branch = git::current_branch()?; let remote_url = git::remote_url(&args.remote)?; let target_branch = match args.target { Some(t) => t, None => git::remote_default_branch(&args.remote).unwrap_or_else(|| "main".to_string()), }; let base = format!("{}/{}", args.remote, target_branch); if !git::ref_exists(&base) { git::fetch(&args.remote, &target_branch)?; }
let commits = gitpatch::commits_since(&base)?; match commits.len() { 0 => bail!("no commits between {base} and HEAD; nothing to stack"), 1 => bail!( "one commit between {base} and HEAD; a stack of one is a pull request\n\ `atgc pr create` is the command for it" ), _ => {} } refuse_merges(&base)?;
let repo = resolve::repo_ref(&remote_url).await?;
// A branch that already has records is not this command's to write to. // The check needs only the acting account's own pulls — a stack this // command would collide with is one it (or the web) wrote as this // account — so it reads the PDS alone: complete for own records, and // standing when Bobbin is not, which is chronic. Auto here meant a // Bobbin stall could block a first-ever stack it had nothing to say // about. Truncation still refuses: a cap that hides the existing stack // would let this mint a duplicate. let listing = listing::repo_rows(listing::Source::PDS, &repo.did).await?; if !listing.complete() { bail!( "the pull listing hit its page cap, so an existing stack on {branch} could \ be invisible\n\ refusing to create one that might collide with it" ); } let rows = listing.rows; let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect(); // **The branch's existing pull, if it has one, becomes this stack's // bottom member rather than blocking it.** // // A stack almost never starts as one: it starts as `pr create`, and the // second feature arrives later. Refusing here left that person nowhere // to go — `stack create` said "already has a pull request", `stack // resubmit` said "not stacked", and `stack link` refuses two pulls whose // patches overlap, which is exactly what `pr create` produces for a // branch sitting on top of another. Three commands, three refusals, and // the shape they all described is the ordinary one. // // Adopting keeps the number, the rounds and the comments — the same // promise `link` makes — and the reconcile below decides what the record // needs: its patch spans the whole branch and a member's spans one cut, // so in practice that is a new round saying so. let adopt = match listing::for_branch(items.iter().copied(), &branch) { None => None, Some(existing) => { let uri = existing["uri"].as_str().unwrap_or_default().to_string(); let title = existing["value"]["title"].as_str().unwrap_or("(untitled)"); if let Some(chain) = super::chain_containing(&items, &uri, &super::closed_uris(&rows))? { bail!( "branch {branch} already has a stack of {}: `atgc stack view` shows it\n\ `atgc stack resubmit` reconciles it with the branch as it stands now", chain.size() ); } // Only an open pull is a member to build on. A merged or closed // one is history, and `?` is a state no listing could settle — // adopting either would hang a new stack off a record that is // not going to be reviewed. let state = super::state_of(&rows, &uri); if state != "open" { bail!( "branch {branch}'s pull request is {state}: {title}\n\ a stack builds on an open pull; start this one from a branch of \ its own, or reopen that pull first" ); } Some(existing.clone()) } };
// The session is settled before the branch is. `--add-change-ids` moves // `refs/heads/<branch>` and this used to run a hundred lines below it, // so the ordinary case — a session that expired an hour ago, which is // what `oauth::client::client_metadata` documents — rewrote four commits and then // failed with "could not refresh the session", leaving new shas and no // pull requests. Resuming a session depends on nothing the rewrite // touches, so it is a prerequisite and is taken as one. // // A dry run keeps needing no session at all: it rewrites nothing, sends // nothing, and is the thing to reach for when there is any doubt. let agent = match args.dry_run { true => None, false => Some(auth::agent_for_did(&selection.did).await?), };
// Cut and plan the branch as it stands, *before* anything rewrites it: // every refusal below is decidable from these commits, and deciding them // after the rewrite is how a refusal used to arrive with the branch // already moved. Planning writes nothing anywhere, so doing it twice // costs only the second pass. let cut_and_plan = |commits: &[String]| -> Result<(Cut, Vec<Planned>)> { let (marks, stranded) = super::marks::positions_and_stranded(&branch, commits); warn_stranded_marks(&stranded); let cut = match args.per_commit { true => Cut::per_commit(commits.len()), false => cut_at_marks(commits.len(), &marks), }; let planned = plan_groups(&selection.did, commits, &cut.groups, true)?; Ok((cut, planned)) }; let (cut, planned) = cut_and_plan(&commits)?; refuse_unplannable(&planned, commits.len(), &base)?; let unmarked = cut.labels.iter().flatten().next().is_none();
// The adopted pull is the bottom member, and its identity is its // commits rather than its change-ids. // // `reconcile` matches members by the `Change-Id:` headers in their // patches, which every stack member has because `stack create` injects // them. A pull opened by `pr create` does not: its patch is the knot's // own `compare` output, and `knotserver/git/diff.go` writes that header // only from a commit object's jj change-id, never from a `Change-Id:` // message trailer. Handing one to `reconcile` therefore refuses it as a // stack that "predates change-id correlation", which is the wrong thing // to say about a pull that was opened yesterday. // // What it does have is commits, and they are the branch's own — so the // check is that its patch and this branch still describe some of the // same work. A pull whose commits have all left is one this branch was // replaced under, and rewriting it to a cut of the new history would // quietly repoint somebody's review at unrelated work. let adopted: Option<OldMember> = match &adopt { None => None, Some(item) => { let m = old_member(item, "open").await?; let its = gitpatch::commit_shas(&m.latest_patch); if !its.is_empty() && !its.iter().any(|sha| commits.contains(sha)) { bail!( "{} ({}) is this branch's pull request, but none of the commits in \ its latest round is still on the branch\n\ adopting it as this stack's bottom would repoint its review at \ work it was not opened for\n\ `atgc pr resubmit` brings it up to the branch first, or start the \ stack from a branch of its own", m.title, m.rkey, ); } Some(m) } };
// An unmarked branch of any length opens a pull request per commit, and // that is a fine default for two or three — it is the jj-shaped // workflow. Past that it is almost never what somebody meant: the usual // cause is not knowing marks exist, and the result is eight pull // requests nobody can review, each one commit of a change. Eight records // are also eight records to close by hand, since a stack is not // something `create` can undo. // // So the wide case says what it is about to do and asks to be told // again, either by marking the cuts or by saying `--per-commit` out // loud. `stack.askWhenUnmarked false` is how a checkout stops being // asked. if !args.per_commit && unmarked && commits.len() > UNMARKED_LIMIT && asks_about_unmarked() { bail!( "{} commits over {base}, and no marks: that is {} pull requests of one commit \ each\n\ cut it where the changes are — `atgc stack mark <rev>`, once per pull request \ — or say `--per-commit` if a pull per commit is what you want\n\ `git config stack.askWhenUnmarked false` stops this checkout asking", commits.len(), commits.len(), ); } let rewrite_pending = ensure_change_ids( &base, &commits, args.add_change_ids, args.dry_run, rewritten, )?;
// Re-read and re-plan only when a rewrite really ran: it moved every sha // from the first missing id onward, and the marks with them. // // The commits themselves are no longer wanted past this point — every // refusal that reads them now happens above the rewrite, which is the // whole of the ordering fix — so only the cut and the plan come back // out. `planned` carries the new shas it was built from. let (cut, planned) = match rewritten.is_some() { false => (cut, planned), true => cut_and_plan(&gitpatch::commits_since(&base)?)?, }; // A commit whose id the pending rewrite has not minted yet is planned // under the id that rewrite will give it, which only a dry run can see.
// Compressed once, for the plan's sizes and the uploads both — every // patch was being gzipped twice, and the second run could only ever // agree with the first. let mut gzipped: Vec<Vec<u8>> = Vec::with_capacity(planned.len()); for p in &planned { gzipped.push(gzip(&p.patch)?); }
let mut report = StackCreatedJson { dry_run: args.dry_run, repo_did: repo.did.clone(), target_branch: target_branch.clone(), source_branch: branch.clone(), total: planned.len(), members: planned .iter() .enumerate() .map(|(i, p)| CreatedMemberJson { position: i + 1, sha: p.sha().to_string(), shas: p.shas.clone(), commits: p.commits(), change_id: p.change_id().to_string(), change_ids: p.change_ids.clone(), adopted: false, branch: cut.labels.get(i).cloned().flatten(), title: p.subject.clone(), patch_bytes: p.patch.len(), patch_gzip_bytes: gzipped[i].len(), uri: None, images: p.images.as_ref().map(|i| i.listed()).unwrap_or_default(), }) .collect(), rewrite_pending, pushed: false, url: format!("{}/pulls", repo.web_url), };
// Said before the plan is printed, and filled into the report, so a dry // run shows which pull is being built on rather than describing a stack // of records that do not all need creating. if let Some(m) = &adopted && let Some(member) = report.members.first_mut() { member.adopted = true; member.uri = Some(m.uri.clone()); }
if !args.json { println!("target: {} branch {}", repo.linked(), target_branch); // Where it is going, not just where it is: the branch is published // to `remote` before any record claims it, and the git progress that // follows this line is that push. println!("source: branch {branch} -> {}", args.remote); match cut.labels.iter().flatten().count() { 0 => println!( "cut: no marks on this branch: a pull per commit \ (`atgc stack mark <rev>` cuts it)" ), n => println!("cut: {n} mark(s), each ending a pull"), } println!("stack: {} pulls, bottom first:", planned.len()); for (i, p) in planned.iter().enumerate() { let gzipped = &gzipped[i]; // The change-id is the one identifier that will survive every // rewrite, so the plan names it next to the sha that will not. // // Marked when it is cut, and that mark earns its column. A // change-id is 40 hex characters after the `I`; nine of them in // a fixed column reads like a whole value, and somebody // rebuilding a branch by hand copied what they saw into fresh // `Change-Id:` trailers. Those commits then matched *no* pull, // and `stack resubmit` correctly offered `--prune` — which // would have deleted four pull records and every review comment // on them. `--json` carries the id whole, and is the thing to // read when the value is needed rather than recognized. let id_short = ellipsize_change_id(p.change_id()); let carried = match (cut.labels.get(i).and_then(Option::as_deref), p.commits()) { (None, 1) => String::new(), (None, n) => format!("{n} commits, "), (Some(name), 1) => format!("{name}, "), (Some(name), n) => format!("{name}, {n} commits, "), }; println!( " {}/{} {} {id_short} {} ({carried}{} bytes, {} gzipped)", i + 1, planned.len(), p.short(), p.subject, p.patch.len(), gzipped.len(), ); // A member of several commits says which, in order: the count // alone leaves the reader checking the marks against `git log` // by hand, which is the moment a miscut is cheapest to catch and // the most expensive to miss. if p.commits() > 1 { for (sha, subject) in p.shas.iter().zip(subjects_of(&p.shas)?) { println!(" commit {} {subject}", &sha[..7.min(sha.len())]); } } for line in p.images.as_ref().map(|i| i.describe()).unwrap_or_default() { println!(" image: {line}"); } } } // A stack cut at branch tips only stays cut if those tips travel with // their commits, and a plain `git rebase` leaves them behind — on // commits the branch no longer has, where the next reconcile sees a // stack with no cuts in it at all. git has moved them since 2.38, but // only when asked, so the run that first depends on it says so. if !args.json && cut.labels.iter().flatten().next().is_some() && !gitpatch::rebase_updates_refs() { crate::term::say::note!( Git, "keep this stack in step with `atgc stack sync`, which rebases and reconciles \ together; a plain `git rebase` leaves these marks behind unless it is given \ --update-refs" ); } if !args.json && let Some(m) = &adopted { // The at-uri rather than a link: a pull's number is the appview's // and no record holds it, and this line is printed before anything // is written, so there is nothing yet to look up. println!("adopt: {} keeps its rounds as the bottom member", m.uri); } if args.dry_run { if args.json { return crate::term::jsonout::emit(&report); } if rewrite_pending { println!( "would rewrite the branch first to add the missing Change-Id trailers, \ so shas and patch sizes above are approximate" ); } println!("dry run; nothing sent"); return Ok(()); }
// The claim every record below makes, made true first. Nothing before // this point left the machine; from here the branch is on the target's // knot and the knot has confirmed what it holds. // A create has no prior round to lease against: an unleased push is // fast-forward-only, which is the right answer for a branch nothing has // recorded yet. report.pushed = publish_branch(&args.remote, &branch, &target_branch, &repo, &planned, None).await?;
let agent = agent.expect("a run that is not a dry run resumed its session above");
// A stack with nothing to adopt is a walk of nothing but `Add`s, which // is what this was before it could adopt anything. Going through the // reconcile's own vocabulary either way is what lets `chain_ops` be the // one walk both commands use. let chain: Vec<Slot> = match &adopted { // The adopted pull is position zero, and its record needs a round // unless its patch already says exactly what the bottom cut says. Some(m) => { let bottom = match m.latest_patch == planned[0].patch { true => Slot::Keep { member: 0, relink: false, }, false => Slot::Update { index: 0, member: 0, }, }; std::iter::once(bottom) .chain((1..planned.len()).map(|index| Slot::Add { index })) .collect() } None => (0..planned.len()) .map(|index| Slot::Add { index }) .collect(), }; let old: Vec<OldMember> = adopted.into_iter().collect(); let pds = match old.is_empty() { true => None, false => Some(crate::clients::atproto::did::pds_or_fail(&selection.did).await?), }; let adopted_note = old.first().map(|m| { ( m.uri.clone(), m.rounds + usize::from(chain[0].writes_a_round()), ) });
let (ops, uris) = chain_ops( &agent, pds.as_deref(), &selection.did, &repo.did, &target_branch, &branch, &chain, &old, &planned, &mut gzipped, // Nothing to hang from: `create` is the command for a branch that // has no chain yet, and an adopted member is the bottom rather than // something below it. None, ) .await?;
// The last thing before anything leaves the machine: read the chain // these ops would produce, and refuse it if the reader could not. // `super::refuse_new_damage` owns every rule about a well-formed chain, // so a verb added later gets them without knowing they exist. super::refuse_new_damage(&items, &selection.did, &ops, &super::closed_uris(&rows))?;
// **A lease, but only when there is something to lose.** A stack built // from nothing writes only creates: no record can be clobbered, and an // unleased batch is the right answer. Adopting changed that — the bottom // member is an `Op::Update` against a record that already exists, and // without a lease it lands whatever happened to that pull since it was // read. Another session appending a round in between would be // overwritten silently, which is the failure `resubmit` has always // leased against and this inherited the need for the moment it learned // to adopt. let swap_commit = match pds.as_deref() { // Nothing adopted, so nothing to overwrite. None => None, Some(pds) => Some(crate::clients::atproto::pds::latest_commit(pds, &selection.did).await?), }; crate::clients::atproto::record::batch( &agent, "stack", &selection.did, ops, swap_commit.as_deref(), ) .await?; for (member, uri) in report.members.iter_mut().zip(&uris) { member.uri = uri.clone(); } if args.json { return crate::term::jsonout::emit(&report); } if let Some((uri, round)) = &adopted_note { println!("adopted {uri} as the bottom (round {round})"); } for (member, uri) in report.members.iter().zip(&uris) { if let Some(uri) = uri && Some(uri.as_str()) != adopted_note.as_ref().map(|(u, _)| u.as_str()) { let _ = member; println!("created {uri}"); } } println!( "view: {}", crate::term::hyperlink::url(&format!("{}/pulls", repo.web_url)) ); Ok(())}
/// Push the branch, then have the target's knot say what it now holds/// between the target and it.////// The push is what makes `source: {branch}` a fact rather than a claim, and/// the compare is the only proof available that it landed: unlike/// `pr create`, a stack cannot take its patch bytes from the answer — a/// member's patch is one commit's `format-patch` carrying an injected/// `Change-Id:` header, and `sh.tangled.repo.compare` answers about a range —/// so the answer is read for what it says rather than for what it contains.////// Refusals and notes, in the order they can happen:////// * an empty comparison stops the create, which is `handleBranchBasedPull`'s/// own refusal ("No commits between target and source") in its own words;/// * a count that disagrees with the plan is a note rather than a refusal —/// the knot is answering about its target branch, which can legitimately/// have moved under a stale `origin/main`, and the records are still one/// per local commit either way;/// * a mailbox missing the planned change-ids means the *web* resubmit of/// this stack will refuse, because `knotserver/git/diff.go` writes that/// header only from a commit object's jj `change-id` and never from a/// message trailer.async fn publish_branch( remote: &str, branch: &str, target_branch: &str, repo: &resolve::RepoRef, planned: &[Planned], expected: Option<&str>,) -> Result<bool> { // The top of the plan is the top of the branch, which is what the remote // has to end up holding. let head = planned .last() .and_then(|p| p.shas.last()) .map(String::as_str) .unwrap_or_default(); // The one failure this is expected to hit is no push access, and this is // the place to say that a stack has no `--patch-only` to fall back on. // Naming `pr create --patch-only` is not a consolation prize: it is the // only shape atgc can write for a knot it cannot push to, and it is a // single pull by construction. let pushed = crate::cmd::publish_branch( remote, branch, head, expected, &format!( "A stack cannot be published without the branch: every member records \n\ `source: {branch}`, and `stack view`, `stack resubmit` and `stack merge` \n\ all find the chain through it.\n\ `atgc pr create --patch-only` opens one patch-based pull instead." ), )?;
let knot = crate::clients::atproto::did::knot_from_did_doc(&repo.did) .await .ok_or_else(|| { anyhow::anyhow!( "no knot in {}'s DID document, so there is nothing to confirm the push with", repo.did ) })?; crate::term::say::step!(Knot, "comparing {target_branch}..{branch} on {knot}..."); let comparison = crate::clients::tangled::compare::compare(&knot, &repo.did, target_branch, branch).await?;
if comparison.commits == 0 || comparison.patch.is_empty() { bail!( "{knot} finds no commits between {target_branch} and {branch}\n\ The branch was pushed; the knot has nothing on it that {target_branch} does not." ); } // Commits against commits. This compared the knot's count to the number // of *members*, which was the same number only while a member was one // commit: a stack of two members holding ten commits tripped it every // time, and said the knot's branch had moved when nothing had. let planned_commits: usize = planned.iter().map(|p| p.commits()).sum(); if comparison.commits != planned_commits { crate::term::say::warning!( Knot, "{knot} counts {} commit(s) between {target_branch} and {branch}, and this \ stack plans {planned_commits} — the knot's {target_branch} has moved under \ {remote}/{target_branch}", comparison.commits, ); } if comparison.binary_omitted { crate::term::say::warning!( Knot, "{knot} left binary payloads out of its patch; the stack's own patches are \ formatted here and are unaffected" ); } if let Some(missing) = missing_from_knot(&comparison.patch, planned) { crate::term::say::note!( Knot, "{knot} formats {missing} of these commits with no `Change-Id:` header, so \ resubmitting this stack from the web will refuse.\n\ The knot reads that header from a jj change-id in the commit object and \ not from a `Change-Id:` trailer. `atgc stack resubmit` is unaffected." ); } crate::logging::debug::log(format!( "compare {target_branch}({})..{branch}({}) merge-base {}: {} commit(s), {} bytes", comparison.rev1, comparison.rev2, comparison.merge_base, comparison.commits, comparison.patch.len() )); Ok(pushed)}
/// How many planned change-ids the knot's own mailbox does not carry as a/// `Change-Id:` header, or `None` when it carries them all.////// Every id of every member, not one per member: a member is a run of/// commits and the knot formats each of them, so counting members would cap/// the answer at one missing id per pull request and report "1 of these/// commits" for a member that lost three.////// Matched against the whole mailbox rather than per patch: this is a yes/no/// about whether the knot can see these ids at all, and splitting the/// mailbox to say which commit lost one would be a second patch parser for/// an answer that is the same for every commit on a branch.fn missing_from_knot(mailbox: &str, planned: &[Planned]) -> Option<usize> { let missing = planned .iter() .flat_map(|p| &p.change_ids) .filter(|id| !mailbox.contains(&format!("Change-Id: {id}"))) .count(); (missing > 0).then_some(missing)}
/// Refuse, rewrite, or (on a dry run) defer: every commit of `base..HEAD`/// must carry a change-id before a stack write goes anywhere. Returns/// whether a rewrite is still pending — true only on a dry run that would/// have rewritten.////// `rewritten` is an out-parameter rather than part of the return, because/// what the callers need from it is not a result they branch on but a fact/// that outlives the call: [`note_rewrite`] reads it after the command has/// failed, from a frame this function has long since left.fn ensure_change_ids( base: &str, commits: &[String], add_change_ids: bool, dry_run: bool, rewritten: &mut Option<gitpatch::Rewrite>,) -> Result<bool> { let mut missing: Vec<String> = Vec::new(); for sha in commits { if gitpatch::change_id(sha)?.is_none() { missing.push(sha.clone()); } } if missing.is_empty() { return Ok(false); } if !add_change_ids { let mut rows = Vec::with_capacity(missing.len()); for sha in &missing { rows.push((&sha[..7.min(sha.len())], subject_of(sha)?)); } let lines = two_column_listing(rows); // The commits already made are this run's problem and only a rewrite // fixes them — but the *next* ones are fixable for free, and this is // the moment it is obvious the hook is missing. Installed quietly // and reported only when something was written, because a refusal // with two remedies in it reads as one remedy nobody can find. let hook = crate::clients::git::hooks::install_commit_msg(Path::new(".")); let hooked = match &hook { Ok(installed) => installed.describe().map(|line| format!("\n{line}")), Err(e) => { crate::logging::debug::dump_err("commit-msg hook install failed", e); None } }; bail!( "{} of {} commit(s) carry no change-id:\n{lines}\ a change-id is what matches each pull to its commit across rewrites\n\ rerun with --add-change-ids to rewrite {base}..HEAD with Change-Id trailers \ (shas change; `git reflog` records the old tip), or let jj >= 0.29 write \ them via git.write-change-id-header{}", missing.len(), commits.len(), hooked.unwrap_or_default(), ); } if !gitpatch::working_tree_clean()? { bail!( "the working tree has uncommitted changes\n\ commit or stash them first; --add-change-ids rewrites the branch" ); } if dry_run { // A dry run must not rewrite, so the ids printed for these commits // are the ones the rewrite would mint. return Ok(true); } *rewritten = gitpatch::rewrite_with_change_ids(base)?; if let Some(rewrite) = rewritten { // A step this command took on the checkout rather than a fact about // the stack, so it goes where the other steps go. The old tip is // spelled out here as well as in every failure below: a person // reading a successful run should not have to go to the reflog to // learn what the sha in their scrollback used to be. crate::term::say::step!( Git, "rewrote the branch: {} commit(s) gained a Change-Id trailer (it was at {}, \ which `git reflog {}` also records)", rewrite.added, rewrite.was, rewrite.branch, ); } Ok(false)}
/// Upload one planned commit's patch and mint its create op — the one/// definition of "a new stacked pull". `stack create` and the reconcile's/// Add slot had each grown a copy, and the blobs/body/source fields were/// one lexicon change away from disagreeing.#[allow(clippy::too_many_arguments)]async fn create_pull_op( agent: &jacquard::client::Agent<crate::clients::atproto::oauth::Session>, ticker: &mut Ticker, me: &str, repo_did: &str, target_branch: &str, branch: &str, parent: &Option<String>, p: &Planned, gzipped: Vec<u8>,) -> Result<(Op, String)> { let gzip_len = gzipped.len(); let blob = upload_patch_blob( agent, gzipped, format!( "uploading patch blob for {} ({gzip_len} bytes gzip)", p.short() ), Some(p.short()), ) .await?; // Minted here, monotonic, so each record can name its parent before // anything is sent. let rkey = ticker.next(None).to_string(); let uri = format!("at://{me}/{PULL_NSID}/{rkey}"); let image_blobs = upload_member_images(agent, p).await?; let pull = Pull { title: p.subject.clone().into(), // Already in published form: predictions were rewritten in at // planning time. body: p.body.clone().map(Into::into), target: crate::lexicon::tangled::pull_target(repo_did, target_branch)?, source: Some(Source { branch: branch.to_string().into(), repo: None, extra_data: None, }), dependent_on: parent.clone().map(|s| AtUri::new(s.into())).transpose()?, rounds: vec![Round { patch_blob: blob.into(), created_at: Datetime::now(), extra_data: None, }], blobs: image_blobs.map(|bs| bs.into_iter().map(Into::into).collect()), created_at: Datetime::now(), mentions: None, references: None, extra_data: None, }; pull.validate() .map_err(|e| anyhow::anyhow!("{e}")) .context(format!( "pull record for {} would be refused by the lexicon", p.short() ))?; let op = Op::Create { nsid: PULL_NSID, rkey: Key::any_owned(&rkey).map_err(|e| anyhow::anyhow!("bad TID {rkey}: {e}"))?, value: serde_json::to_value(&pull)?, }; Ok((op, uri))}
/// Validate an edited member and turn it into the op that writes it back.////// Every write path that opens a member's record ends this same way, and/// each one used to end it in its own hand-written copy — check the lexicon,/// spell the record key, serialize. Four copies of the last step before a/// record leaves the machine is four chances for one of them to skip the/// check.fn update_member_op(rkey: &str, pull: &Pull) -> Result<Op> { pull.validate() .map_err(|e| anyhow::anyhow!("{e}")) .context(format!( "pull record for {rkey} would be refused by the lexicon" ))?; Ok(Op::Update { nsid: PULL_NSID, rkey: Key::any_owned(rkey).map_err(|e| anyhow::anyhow!("bad record key {rkey}: {e}"))?, value: serde_json::to_value(pull)?, })}
/// Point an existing member at a new parent, touching nothing else.////// Its rounds, title, body and blobs are all left exactly as they are: the/// chain link is the only field that has stopped being true.////// This existed four times — here, in `resubmit`'s retire loop, in `unlink`/// and in `link` — because it is one line of intent wrapped in six lines of/// ceremony, and rewriting the ceremony reads as cheaper than finding it./// The copies took a `None` parent, an `Option<String>` and a/// `&Option<String>` respectively, which is why none of them looked like the/// others.async fn relink_op(pds: &str, me: &str, rkey: &str, parent: Option<&str>) -> Result<Op> { let (mut pull, _cid) = fetch_member(pds, me, rkey).await?; pull.dependent_on = parent .map(|s| AtUri::new(s.to_string().into())) .transpose()?; update_member_op(rkey, &pull)}
/// Append a round to an existing member, and relink it while the record is/// open anyway.////// Shared by `stack resubmit`, which does this to a member of a chain whose/// commits moved, and by `stack create`, which does it to the one unstacked/// pull it adopts as a new stack's bottom — a pull whose patch came from/// `pr create` and so spans the whole branch, where the member's patch is/// only its own cut. Two copies of a record mutation this shaped is two/// chances to relink one and not the other, and the round is the half that/// cannot be undone.async fn append_round_op( agent: &jacquard::client::Agent<crate::clients::atproto::oauth::Session>, pds: &str, me: &str, m: &OldMember, p: &Planned, parent: &Option<String>,) -> Result<Op> { let gzipped = gzip(&p.patch)?; let gzip_len = gzipped.len(); let blob = upload_patch_blob( agent, gzipped, format!( "uploading round blob for {} ({gzip_len} bytes gzip)", m.rkey ), Some(p.short()), ) .await?; let (mut pull, _cid) = fetch_member(pds, me, &m.rkey).await?; pull.rounds.push(Round { patch_blob: blob.into(), created_at: Datetime::now(), extra_data: None, }); pull.dependent_on = parent.clone().map(|s| AtUri::new(s.into())).transpose()?; // Titles follow the commit, as Tangled's resubmit has them. The body // does too, but only while it is still the one the last round generated: // see `body_is_its_commits`, which is what keeps a hand-written // description from being reverted to `%b` by every round. It was // rewritten at planning time, so what goes on the wire here is final // either way. pull.title = p.subject.clone().into(); if m.body_is_its_commits() { pull.body = p.body.clone().map(Into::into); } if let Some(new_blobs) = upload_member_images(agent, p).await? { let existing: Option<Vec<jacquard::types::blob::Blob>> = pull .blobs .take() .map(|bs| bs.into_iter().map(Into::into).collect()); pull.blobs = crate::cmd::images::merge_blobs(existing, new_blobs) .map(|bs| bs.into_iter().map(Into::into).collect()); } update_member_op(&m.rkey, &pull)}
/// **Walk a reconcile's chain and build the ops that write it.**////// One walk, two callers. `stack create` and `stack resubmit` differ in what/// they start from — create's `old` is the branch's one unstacked pull, or/// nothing at all, and resubmit's is the chain already recorded — and in/// nothing else: a member is kept, relinked, given a round or minted, and/// each one becomes the parent of the next. Two copies of that loop is two/// chances to thread `dependentOn` correctly in one and not the other, which/// is the single field this whole epic has been about.////// `gzipped` is taken by index rather than consumed, because the slots do not/// visit the planned members in order and a `Keep` visits none.////// Returns the ops in the order they must be applied, and the at-uri each/// planned member ended up at — minted for an `Add`, the existing record's/// for everything else. Read off the walk rather than back off the batch,/// since an updated record's uri is one the batch never answers with.#[allow(clippy::too_many_arguments)]async fn chain_ops( agent: &jacquard::client::Agent<crate::clients::atproto::oauth::Session>, pds: Option<&str>, me: &str, repo_did: &str, target_branch: &str, branch: &str, chain: &[Slot], old: &[OldMember], planned: &[Planned], gzipped: &mut [Vec<u8>], anchor: Option<String>,) -> Result<(Vec<Op>, Vec<Option<String>>)> { let mut ticker = Ticker::new(); let mut ops: Vec<Op> = Vec::new(); let mut uris: Vec<Option<String>> = vec![None; planned.len()]; // Where the bottom member hangs from. `None` for a stack that starts at // the target branch; a merged member's at-uri after a bottom-merge and a // rebase, which is how Tangled's own resubmit leaves the chain connected // to its merged history. Seeding this wrongly is invisible until // somebody looks at the stack a merge later, so it is a parameter rather // than a default. let mut parent: Option<String> = anchor; for slot in chain { match slot { Slot::Add { index } => { let (op, uri) = create_pull_op( agent, &mut ticker, me, repo_did, target_branch, branch, &parent, &planned[*index], std::mem::take(&mut gzipped[*index]), ) .await?; ops.push(op); uris[*index] = Some(uri.clone()); parent = Some(uri); } Slot::Update { index, member } => { let m = &old[*member]; let pds = pds.expect("a member to update means a chain was read"); ops.push(append_round_op(agent, pds, me, m, &planned[*index], &parent).await?); uris[*index] = Some(m.uri.clone()); parent = Some(m.uri.clone()); } Slot::Keep { member, relink } => { let m = &old[*member]; if *relink { let pds = pds.expect("a member to relink means a chain was read"); ops.push(relink_op(pds, me, &m.rkey, parent.as_deref()).await?); } parent = Some(m.uri.clone()); } // Merged, and never touched: it holds its place and the members // above it hang off it. Slot::Frozen { member } => { parent = Some(old[*member].uri.clone()); } } } Ok((ops, uris))}
/// Render an indented two-column listing, one row per line, for embedding/// above a refusal's explanation in a `bail!`. Named apart from/// `read as listing` above, which is a module and lives in a different/// namespace but would still confuse a reader hunting for what `listing`/// means here. Every place that refuses a whole *set* of commits or pulls —/// rather than one, which fits in a sentence — builds one of these first so/// the reader can see which ones without cross-referencing shas by hand.fn two_column_listing<L: std::fmt::Display, R: std::fmt::Display>( rows: impl IntoIterator<Item = (L, R)>,) -> String { let mut out = String::new(); for (left, right) in rows { out.push_str(&format!(" {left} {right}\n")); } out}
/// How wide a change-id column is: nine characters, the `I` and eight of/// the forty after it. Enough to recognize one member from another in a/// list, and never enough to retype.const CHANGE_ID_SHOWN: usize = 9;
/// A change-id shortened for a column, with an ellipsis when it was cut.////// The ellipsis is the whole function. Without it the column is nine/// characters of a forty-one character value with nothing saying so, which/// reads as the value itself — and a change-id that is retyped short matches/// no pull at all. See the call site for what that cost once.fn ellipsize_change_id(id: &str) -> String { match id.char_indices().nth(CHANGE_ID_SHOWN) { Some((at, _)) => format!("{}…", &id[..at]), None => id.to_string(), }}
/// The branch that ends a member, in the shape a plan line wants it: `""` when/// nothing marks it, so the caller can interpolate it unconditionally.fn label_of(cut: &Cut, index: usize) -> String { match cut.labels.get(index).and_then(Option::as_deref) { Some(name) => format!(" [{name}]"), None => String::new(), }}
/// How a plan line names the commits behind a member: the sha for the/// ordinary one, and the run's ends plus a count when it carries several,/// since one sha alone reads as a member of one commit.fn describe_commits(p: &Planned) -> String { match p.commits() { 1 => format!("commit {}", p.short()), n => format!( "{n} commits, {}..{}", p.short(), &p.shas[n - 1][..7.min(p.shas[n - 1].len())], ), }}
/// The subjects of a member's commits, in order — one `git log` per commit,/// asked only when a member carries more than one and the plan is about to/// list them.fn subjects_of(shas: &[String]) -> Result<Vec<String>> { shas.iter().map(|sha| subject_of(sha)).collect()}
fn subject_of(sha: &str) -> Result<String> { git::git_in(Path::new("."), &["log", "-1", "--format=%s", sha])}
/// A commit's subject and body, the pull's title and body. `%B` would hand/// back the two joined; asking separately spares re-splitting them on the/// blank line the format already knows about.fn subject_and_body(sha: &str) -> Result<(String, Option<String>)> { let subject = subject_of(sha)?; let body = git::git_in(Path::new("."), &["log", "-1", "--format=%b", sha])?; let body = strip_change_id_trailers(&body); Ok((subject, (!body.is_empty()).then_some(body)))}
/// The commit body minus its `Change-Id:` trailer, which is stack/// bookkeeping the patch carries in its header — not prose for the pull's/// description. The first live stack proved the point: a commit with no/// body beyond its added trailer produced a pull whose entire description/// was `Change-Id: I…`.////// Only the final paragraph is treated as a trailer block, and only the/// Change-Id lines leave it — a `Signed-off-by:` stays, and a Change-Id/// quoted mid-body is prose.fn strip_change_id_trailers(body: &str) -> String { // CRLF first: a body committed verbatim from Windows tooling has // `\r\n\r\n` between paragraphs, which the `\n\n` split below cannot // see — the whole body then read as one trailer block and mid-body // prose mentioning Change-Id was deleted from the pull description. let body = body.replace("\r\n", "\n"); let trimmed = body.trim(); let (head, tail) = match trimmed.rsplit_once("\n\n") { Some((head, tail)) => (Some(head), tail), None => (None, trimmed), }; let kept: Vec<&str> = tail .lines() .filter(|line| !line.trim_start().starts_with("Change-Id:")) .collect(); let tail = kept.join("\n"); match (head, tail.trim().is_empty()) { (Some(head), true) => head.trim().to_string(), (Some(head), false) => format!("{}\n\n{}", head.trim(), tail.trim()), (None, true) => String::new(), (None, false) => tail.trim().to_string(), }}
/// Inject the `Change-Id:` mail header the appview correlates rounds by.////// The header block of a format-patch ends at the first blank line;/// anything after that is the commit message and the diff, where a/// `Change-Id:` line would just be text. A patch that already carries the/// header — none of atgc's own do, but a re-run should not double it — is/// returned untouched.fn with_change_id_header(patch: &str, change_id: &str) -> Result<String> { let Some(split) = patch.find("\n\n") else { bail!("malformed patch: no header block to add Change-Id to"); }; let headers = &patch[..split]; if headers.lines().any(|l| l.starts_with("Change-Id:")) { return Ok(patch.to_string()); } Ok(format!( "{headers}\nChange-Id: {change_id}{}", &patch[split..] ))}
/// Inject one `Change-Id:` header per message of a member's mailbox, in/// order: message *i* gets `change_ids[i]`.////// A one-commit member is the one-message case of this and comes out byte/// for byte what [`with_change_id_header`] alone produced, which is what/// keeps every stack written before members could hold several commits/// comparing equal on its next reconcile.fn with_change_id_headers(patch: &str, change_ids: &[String]) -> Result<String> { let offsets = gitpatch::message_offsets(patch); if offsets.len() != change_ids.len() { bail!( "the patch for this member holds {} message(s) but {} commit(s) went into it; \ refusing to guess which change-id belongs to which", offsets.len(), change_ids.len(), ); } let mut out = String::with_capacity(patch.len() + change_ids.len() * 56); for (i, &start) in offsets.iter().enumerate() { let end = offsets.get(i + 1).copied().unwrap_or(patch.len()); out.push_str(&with_change_id_header(&patch[start..end], &change_ids[i])?); } Ok(out)}
/// How the commits of `base..HEAD` are cut into members.////// A group is a *contiguous run* of commit indexes, bottom first, and the/// groups tile the range in order. One commit per group — the whole range as/// `[[0], [1], [2]]` — is the default and what every stack written before/// this existed looks like.type Groups = Vec<Vec<usize>>;
/// Every commit its own member: the cut for an unmarked branch, and the one `--per-commit`/// exists to override.fn one_group_per_commit(total: usize) -> Groups { (0..total).map(|i| vec![i]).collect()}
/// How many commits an unmarked branch may stack one-per-commit before/// `create` stops to ask.////// Three is a judgement call, not a measurement: two or three single-commit/// pulls is an ordinary jj-shaped stack, and past that the cost of guessing/// wrong — a pile of records to close by hand — outweighs the cost of/// asking.const UNMARKED_LIMIT: usize = 3;
/// Whether this checkout still wants the unmarked-branch question.////// Named for what it does rather than for what it looks like it does. The/// first spelling was `stack.perCommit`, which reads as "always cut one pull/// per commit" — and it never meant that: marks still decide the cut, and/// `--per-commit` is the flag that ignores them. A config whose name/// promises more than it delivers is the same class of mistake as a stack/// that silently re-cuts itself.fn asks_about_unmarked() -> bool { !crate::clients::git::config::local_config(Path::new("."), "stack.askWhenUnmarked") .is_some_and(|v| matches!(v.trim(), "false" | "0" | "no" | "off"))}
/// A cut branch: the groups, and the branch name that ended each one.////// The names are not written to any record — a member's source branch is the/// branch being stacked, as it has always been — but they are what the plan/// prints, and the whole reason this is legible: "part1, part2, feature"/// says where the cuts are in a way that "2,1,3" never did.struct Cut { groups: Groups, labels: Vec<Option<String>>,}
impl Cut { /// One commit per member: the cut for a branch nothing points into. fn per_commit(total: usize) -> Self { Cut { groups: one_group_per_commit(total), labels: vec![None; total], } }}
/// Cut the range at its recorded marks.////// A mark *ends* a member, so `part1` on the second commit of five makes the/// bottom two one pull request, and the commits above it belong to whatever/// mark ends next — the top always being the branch being stacked, whose own/// tip is HEAD.////// No marks means no cuts, which is one member per commit: what every stack/// looked like before marks existed, and what an unmarked branch still gets./// See [`crate::cmd::stack::marks`] for why only recorded branches count.fn cut_at_marks(total: usize, marks: &[(usize, String)]) -> Cut { if marks.is_empty() { return Cut::per_commit(total); } let mut groups: Groups = Vec::new(); let mut labels: Vec<Option<String>> = Vec::new(); let mut current: Vec<usize> = Vec::new(); for i in 0..total { current.push(i); let ends_here = marks.iter().find(|(at, _)| *at == i); let last = i + 1 == total; if let Some((_, name)) = ends_here { groups.push(std::mem::take(&mut current)); labels.push(Some(name.clone())); } else if last { groups.push(std::mem::take(&mut current)); // The top member ends at HEAD, which is the branch being // stacked: named by the caller's own listing, not here. labels.push(None); } } Cut { groups, labels }}
// ---------------------------------------------------------------------------// `stack resubmit`// ---------------------------------------------------------------------------
/// One member of `stack resubmit --json`, in the fate the reconcile/// assigned it.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct ReconciledMemberJson { /// 1-based from the bottom, as everywhere else a stack is numbered. pub position: usize, /// What happens to this member: `update` (a round is appended), /// `relink` (only its place in the chain moved), `keep` (nothing at /// all), `merged` (frozen; never touched) or `add` (a new pull for a /// new commit). Five words, closed — the text view spells each out in /// a sentence, and a caller should not have to parse one. pub action: &'static str, pub title: String, /// The existing record's key. `null` for an `add`, whose key the PDS /// has not minted yet. pub rkey: Option<String>, /// The commit this member will carry — its bottom one, when it carries /// several. `null` for a member no commit touches — `keep`, `relink` /// and `merged`. pub sha: Option<String>, /// Every commit the member will carry, bottom first; empty wherever /// `sha` is null. #[serde(default)] pub shas: Vec<String>, /// Rounds the record will hold once this run is done: one more than it /// had for an `update`, unchanged otherwise, and 1 for an `add`. pub rounds: usize, /// True on an `update` whose stored description has been written over /// since its last round — by `pr edit` or Tangled's edit box — and so /// is left exactly as it is instead of being regenerated from the /// commit message. Always false for every other fate: nothing else /// touches a body at all. pub body_kept: bool, pub images: Vec<crate::cmd::images::ImageJson>,}
/// A member whose commit left the branch, as `--prune` deletes it.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct DroppedJson { pub rkey: String, pub uri: String, pub title: String,}
/// What `stack resubmit` did, or would have done.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct StackResubmittedJson { pub dry_run: bool, /// Whether the branch was pushed to `remote` before the records were /// written. `false` covers both halves of "did not need to": a dry run, /// and a remote already holding this exact head — the ordinary case for /// anyone who pushed by hand first. pub pushed: bool, /// `false` when the stack already matched the branch — the no-op that /// makes a rerun safe. Nothing is written and the command exits 0, so /// this is the field that says which happened. pub changed: bool, pub repo_did: String, pub target_branch: String, pub source_branch: String, pub total: usize, /// Top first, matching the order the text view lists the plan in. pub members: Vec<ReconciledMemberJson>, /// Only ever non-empty with `--prune`; without it a vanished *open* /// member is an error rather than a deletion. pub drops: Vec<DroppedJson>, /// Closed members the branch no longer carries: kept, and unlinked from /// the chain. No flag authorizes these, because the record survives — /// the only thing written is the removal of its `dependentOn`, without /// which the member above it and this one would both hang off the same /// parent. They are the reason `total` can fall with nothing deleted. pub retired: Vec<DroppedJson>, /// A merged member the new bottom stays chained to, keeping the stack /// connected to its landed history. `null` when there is none. pub anchor: Option<DroppedJson>, /// Every record the atomic batch wrote, in the order it wrote them. /// Empty on a dry run and on a no-op. pub wrote: Vec<String>, pub url: String,}
/// One reconcile slot, as `--json` reports it.////// Pure, and split out for the same reason the row builders in/// [`crate::cmd::pr::read`] are: the five fates are the whole of what a reconcile/// decides, and a test can hold all five still with no network in sight.fn reconciled_member_json( position: usize, slot: &Slot, old: &[OldMember], planned: &[Planned],) -> ReconciledMemberJson { let images = |index: usize| { planned[index] .images .as_ref() .map(|i| i.listed()) .unwrap_or_default() }; match slot { Slot::Update { index, member } => ReconciledMemberJson { position, action: "update", title: old[*member].title.clone(), rkey: Some(old[*member].rkey.clone()), sha: Some(planned[*index].sha().to_string()), shas: planned[*index].shas.clone(), rounds: old[*member].rounds + 1, body_kept: !old[*member].body_is_its_commits(), images: images(*index), }, Slot::Keep { member, relink } => ReconciledMemberJson { position, action: if *relink { "relink" } else { "keep" }, title: old[*member].title.clone(), rkey: Some(old[*member].rkey.clone()), sha: None, shas: Vec::new(), rounds: old[*member].rounds, body_kept: false, images: Vec::new(), }, Slot::Frozen { member } => ReconciledMemberJson { position, action: "merged", title: old[*member].title.clone(), rkey: Some(old[*member].rkey.clone()), sha: None, shas: Vec::new(), rounds: old[*member].rounds, body_kept: false, images: Vec::new(), }, Slot::Add { index } => ReconciledMemberJson { position, action: "add", title: planned[*index].subject.clone(), // The PDS mints the key for a record that does not exist yet. rkey: None, sha: Some(planned[*index].sha().to_string()), shas: planned[*index].shas.clone(), rounds: 1, body_kept: false, images: images(*index), }, }}
#[derive(clap::Args, Debug)]pub(crate) struct ResubmitArgs { /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// Rewrite base..HEAD to add Change-Id trailers to commits lacking one #[arg(long)] pub add_change_ids: bool, /// Delete the records of pulls whose commits left the branch #[arg(long)] pub prune: bool, /// Ignore the branches pointing into the range and reconcile the stack /// as one pull request per commit #[arg(long)] pub per_commit: bool, /// Describe the reconcile without writing anything #[arg(long)] pub dry_run: bool, /// Print one JSON object describing what was written (or, with /// --dry-run, what would be) instead of the summary lines #[arg(long)] pub json: bool,}
/// One member of the existing stack, read back and readied for the/// reconcile: everything the planner compares lives here, so the planner/// itself needs no network.struct OldMember { uri: String, rkey: String, title: String, /// State label as the listings print it: open, closed, merged, or `?`. state: String, /// The description the record holds now. Paired with `latest_patch`, /// this is what [`OldMember::body_is_its_commits`] compares: what is /// stored against what that patch's own message would generate. body: Option<String>, /// Every change-id in the latest round's patch, one per commit the /// member carries, bottom first — the record itself has no change-id /// field anywhere; the patch headers are the identity. Empty for a /// round that carries none, which is its own refusal. change_ids: Vec<String>, latest_patch: String, dependent_on: Option<String>, /// How many rounds the record already carries, for saying which round /// an update would append. rounds: usize,}
impl OldMember { /// Whether this member claims `id` — any of its commits, not only the /// bottom one, so a commit rewritten in the middle of a member still /// finds its way home. fn owns(&self, id: &str) -> bool { self.change_ids.iter().any(|mine| mine == id) }
/// Whether the description this record holds is still the one its /// latest round's commit message produced — that is, whether anything /// has been written over it since. /// /// This is what stands between a round and somebody's typing. A stacked /// pull's body comes from its commit message, so a resubmit that /// regenerated it unconditionally was right for the bodies nobody had /// touched and silently destructive for every other: `atgc pr edit` and /// Tangled's own edit box both write descriptions — screenshots, /// context, the whole reason a reviewer reads a pull — that no commit /// message has, and each round reverted the lot to `%b`. Thirteen pulls /// across two stacks were rewritten by hand after that happened. /// /// Comparing against the *stored* patch rather than the new commit is /// what makes both halves work: an untouched body still follows an /// amended message, because the round it was generated from is the one /// being replaced. Anything this cannot parse compares unequal and so /// keeps what is stored, which is the safe direction — the commit /// message is recoverable from git and an edited body is not. fn body_is_its_commits(&self) -> bool { comparable_body(self.body.as_deref()) == body_of_patch(&self.latest_patch) }}
/// A body as it is compared: line endings normalized, blank counted as/// absent. `stack create` writes no body at all for a commit with none, but/// `pr edit --body ''` clears one to a field that may be present and empty.fn comparable_body(body: Option<&str>) -> Option<String> { let text = body?.replace("\r\n", "\n"); let text = text.trim().to_string(); (!text.is_empty()).then_some(text)}
/// The pull body a patch's own commit message produces — [`subject_and_body`]'s/// second half, read out of the patch text instead of out of git.////// A format-patch is a mail message: headers, a blank line, the commit/// message body, then the `---` scissors and the diff. So `%b` is what sits/// between the two, and the `Change-Id:` trailer comes off it here for the/// same reason it comes off there. `None` when there is no body, and also/// when the text is not a patch at all — the caller reads both as "not/// generated from this".fn body_of_patch(patch: &str) -> Option<String> { let text = patch.replace("\r\n", "\n"); let message = text.split_once("\n---\n").map_or(text.as_str(), |(m, _)| m); let body = strip_change_id_trailers(message.split_once("\n\n")?.1); (!body.is_empty()).then_some(body)}
/// What the reconcile decided for one position of the new chain, bottom/// first. `Drop`s live outside the chain — they have no position left.#[derive(Debug, PartialEq, Clone)]enum Slot { /// change-id matched and the patch changed: append a round (and set /// `dependentOn` to the new parent while the record is open anyway). Update { index: usize, member: usize }, /// Matched, patch byte-identical. `relink` is whether the chain order /// still moved under it — an update that touches `dependentOn` only. Keep { member: usize, relink: bool }, /// A merged member whose commit is still on the branch: never touched, /// but it holds its place and its successors hang off it. Frozen { member: usize }, /// A commit no record answers to yet. Add { index: usize },}
impl Slot { /// Whether writing this slot appends a round to an existing record. /// `create` says which round number its adopted member is about to be /// on, and that is the only thing the answer is used for. fn writes_a_round(&self) -> bool { matches!(self, Slot::Update { .. }) }}
/// The whole reconcile, decided before anything is written.#[derive(Debug)]struct Plan { /// Bottom-up; parallel to the new commit order except that `Frozen` /// members occupy the positions their commits still hold. chain: Vec<Slot>, /// Open members whose change-id vanished from the branch. Deleting is /// `--prune`'s to authorize; without it, their presence is an error /// upstream of here. drops: Vec<usize>, /// Closed members whose change-id vanished from the branch: kept, and /// unlinked from the new chain. Everything that makes the record worth /// keeping stays — its rounds, its description, the comments explaining /// the close — and its `dependentOn` goes, because the member above it /// has just been relinked past it and two pulls may not share a parent. /// Reported as well as acted on, or a stack would quietly shrink between /// two reconciles. retired: Vec<usize>, /// Merged members whose commits left the branch — the normal remainder /// after a bottom-merge and a rebase. The topmost is the anchor the new /// bottom's `dependentOn` points at, keeping the chain connected to its /// merged history the way Tangled's own resubmit leaves it. anchor: Option<usize>,}
/// Decide the reconcile: match old members to new commits by change-id.////// This is Tangled's own stacked-resubmit algorithm with atgc's refusals/// added: matched means the record survives (a changed patch appends a/// round; identical bytes append nothing, which is what makes a rerun of/// `stack resubmit` a no-op instead of a round per pull); new change-ids/// become new records; vanished ones delete their records — merged members/// excepted, which are never touched, and members whose state cannot be/// pinned, which are refused rather than guessed about.fn reconcile(old: &[OldMember], new: &[Planned], prune: bool) -> Result<Plan> { use std::collections::HashMap;
for member in old { if member.change_ids.is_empty() { bail!( "{} ({}) has no Change-Id header in its latest round, so no commit \ can be matched to it\n\ this stack predates change-id correlation; reconcile it through \ Tangled's web resubmit", member.title, member.rkey, ); } } // One identity per record and per commit, or matching means nothing: // a HashMap would silently keep whichever claimant came last, and the // wrong pull would quietly collect the other's rounds. Every id a member // carries is registered, not just its bottom one, so a member of several // commits is found by any of them. let mut by_id: HashMap<&str, usize> = HashMap::new(); for (i, m) in old.iter().enumerate() { for id in &m.change_ids { if let Some(&prev) = by_id.get(id.as_str()) && prev != i { bail!( "{} ({}) and {} ({}) both answer to change-id {id}\n\ delete or re-round one of them (Tangled's web resubmit can), then rerun", old[prev].title, old[prev].rkey, m.title, m.rkey, ); } by_id.insert(id.as_str(), i); } } if let Some((id, shas)) = duplicate_change_id(new) { bail!( "commits {} all carry the change-id {id}\n\ a cherry-pick copying the trailer is the usual cause; amend all but one \ with a fresh Change-Id, then rerun", shas.join(" and "), ); } // Which record each planned member inherits, decided once, bottom-up. // // The bottom commit's id is the member's identity, so it is tried first; // a member whose bottom commit is brand new still recognizes itself by // any commit it kept. Each record can be claimed once — splitting a // two-commit pull in half leaves the lower half holding the record and // the upper half a new pull, rather than both fighting over it. let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new(); let mut assigned: Vec<Option<usize>> = Vec::with_capacity(new.len()); for planned in new { let mut pick = by_id .get(planned.change_id()) .copied() .filter(|m| !claimed.contains(m)); if pick.is_none() { pick = planned .change_ids .iter() .find_map(|id| by_id.get(id.as_str()).copied()) .filter(|m| !claimed.contains(m)); } if let Some(m) = pick { claimed.insert(m); } assigned.push(pick); } let matched = &claimed;
// Old members the branch no longer carries. let mut drops = Vec::new(); let mut retired = Vec::new(); let mut anchor = None; for (i, member) in old.iter().enumerate() { if matched.contains(&i) { continue; } match member.state.as_str() { // The normal remainder of a merge: its commits left the branch // when the branch was rebased onto the merge. The last one in // chain order is the topmost, and the anchor. "merged" => anchor = Some(i), // Closing a pull is an explicit act, and one people explain: the // close usually comes with a comment saying why the work moved // or was abandoned. Its commits leaving the branch is then the // expected next step, not a discrepancy — so demanding --prune // here offered deletion as the only way forward, and deleting // the record takes the explanation with it. The record is kept // and the new chain routes past it: the same non-destructive // treatment `merged` gets, minus the anchoring, which a pull // that never landed has not earned. What the record does lose is // its `dependentOn` — see the unlinking loop in the batch. "closed" => retired.push(i), // `Conflict` is the status whose next move is "re-read and try // again", which is exactly what the message asks for: nothing is // wrong with the command line, and the same command works once // the index catches up. "?" => { return Err(crate::exit::fail( crate::exit::Exit::Conflict, format!( "{} ({}) is gone from the branch, and its state cannot be \ settled: a merge would be invisible while the index lags\n\ retry when `atgc stack view` shows a state, or reconcile \ through the web", member.title, member.rkey, ), )); } // `"open"` is the only state a vanished member may be dropped // for, and it is named rather than left as the fallthrough. "open" => drops.push(i), // **A state this build has not heard of is not an open one.** // `State::Known` carries whatever string the index returned — // `sources.rs` puts `item["state"]` straight into it — so a // state Tangled adds after this build ships arrives here intact, // and `PullState::from_token` documents that as expected rather // than exceptional. It used to fall into the drop bucket, which // `--prune` deletes: a member whose commits had left the branch // and whose new terminal state this build could not read would // have had its record removed. // // The same answer `"?"` gets, for the same reason and with the // same status: nothing here is wrong with the command line, and // a build that knows the state will do the right thing. // `select_merge_range` already refuses an unknown state and // `refuse_orphaned_by_rewrite` already counts one as live; this // was the only site that treated it as disposable. other => { return Err(crate::exit::fail( crate::exit::Exit::Conflict, format!( "{} ({}) is gone from the branch and reads as `{other}`, which this \ build does not know\n\ it will not be dropped on a guess: a state added since this build \ may well be one that means landed\n\ upgrade atgc, or reconcile through the web", member.title, member.rkey, ), )); } } } if !drops.is_empty() && !prune { let named: Vec<&OldMember> = drops.iter().map(|&i| &old[i]).collect(); return Err(drop_refusal(&named)); }
// The new chain, bottom first. The parent of position 0 is the anchor. let mut chain = Vec::with_capacity(new.len()); let mut parent_uri: Option<String> = anchor.map(|i| old[i].uri.clone()); let mut parent_is_new = false; for (index, planned) in new.iter().enumerate() { match assigned[index] { Some(member) => { let m = &old[member]; let changed = comparable_patch(&m.latest_patch) != comparable_patch(&planned.patch); let relink = parent_is_new || m.dependent_on != parent_uri; match (m.state.as_str(), changed, relink) { ("merged", false, _) => { // Never touched; successors hang off it wherever the // chain says it now sits. chain.push(Slot::Frozen { member }); } ("merged", true, _) => bail!( "{} ({}) is merged, but commit {} would change its patch\n\ rebase the branch past the merge before resubmitting", m.title, m.rkey, planned.short(), ), // The other half of the same lag, and the same `7`. ("?", true, _) | ("?", _, true) => { return Err(crate::exit::fail( crate::exit::Exit::Conflict, format!( "{} ({}) needs updating, and its state cannot be \ settled: it may have been merged while the index \ lags\nretry when `atgc stack view` shows a state, or \ reconcile through the web", m.title, m.rkey, ), )); } (_, true, _) => chain.push(Slot::Update { index, member }), (_, false, relink) => chain.push(Slot::Keep { member, relink }), } parent_uri = Some(m.uri.clone()); parent_is_new = false; } None => { chain.push(Slot::Add { index }); // The parent uri of whatever comes next is minted at write // time; all that matters here is that it will be new. parent_uri = None; parent_is_new = true; } } } Ok(Plan { chain, drops, retired, anchor, })}
/// The refusal for open pulls no commit on the branch answers to any more:/// what `--prune` authorizes, with every record it would delete named.////// Shared, because there are two moments this has to be said. The reconcile/// reaches it after planning, which is the general case. `resubmit` asks the/// same question *before* `--add-change-ids` rewrites anything, where the/// answer is already knowable and the commits the reader would have to go/// looking for are still on the branch under the shas they were printed as.fn drop_refusal(drops: &[&OldMember]) -> anyhow::Error { let lines = two_column_listing(drops.iter().map(|m| (&m.rkey, &m.title))); anyhow::anyhow!( "{} open pull(s) match no commit on the branch any more:\n{lines}\ reconciling would delete their records, and the review comments on them\n\ rerun with --prune to delete them, restore the commits, or close them \ (a closed pull is kept and left out of the chain)", drops.len(), )}
/// The change-id every commit of the range will answer to once/// `--add-change-ids` has run: the one it already carries, or the `I<sha>`/// the rewrite would mint for one that carries none.////// `None` when nothing would be minted. Every commit already having an id/// means the rewrite changes no identity at all, and there is then nothing/// here worth predicting — the reconcile's own answer is the same one.fn change_ids_after_rewrite(commits: &[String]) -> Result<Option<Vec<String>>> { let mut minted = false; let mut ids = Vec::with_capacity(commits.len()); for sha in commits { match gitpatch::change_id(sha)? { Some(id) => ids.push(id), None => { minted = true; ids.push(format!("I{sha}")); } } } Ok(minted.then_some(ids))}
/// Refuse a rewrite that would leave open pulls answering to nothing.////// A minted change-id is `I` followed by the commit's own pre-rewrite sha,/// so it is new by construction and can match no record that already exists./// A branch rebuilt without its `Change-Id:` trailers therefore orphans its/// entire stack the instant the rewrite runs, and the reconcile that follows/// can only report what it finds: N open pulls matching no commit, remedied/// by `--prune`, which deletes those records and every review comment on/// them. That advice used to arrive with the branch already rewritten, which/// is the worst possible moment for it — the reader is being asked to choose/// between two deletions while the shas that would let them check are gone.////// `--prune` skips this for the same reason the reconcile does: somebody who/// passed it has already authorized the deletion.fn refuse_orphaned_by_rewrite(old: &[OldMember], after: &[String], prune: bool) -> Result<()> { if prune { return Ok(()); } let orphaned: Vec<&OldMember> = old .iter() // The three states the reconcile never drops: a merged member is // frozen, a closed one is retired, and one whose state cannot be // settled is its own refusal rather than this one. .filter(|m| !matches!(m.state.as_str(), "merged" | "closed" | "?")) .filter(|m| !after.iter().any(|id| m.owns(id))) .collect(); if orphaned.is_empty() { return Ok(()); } Err(anyhow::anyhow!( "the change-ids --add-change-ids mints come from the commits' own shas, so they \ match no pull that exists: rewriting the branch first would not change this\n{}", drop_refusal(&orphaned), ))}
/// A merge in the range means the branch is not the line a stack is made/// of: `rev-list` hands back both parents' histories interleaved, the merge/// itself has no single patch, and the change-id rewrite would silently/// flatten it — found by trying exactly that against a real branch.fn refuse_merges(base: &str) -> Result<()> { let merges = gitpatch::merges_since(base)?; if merges.is_empty() { return Ok(()); } let mut rows = Vec::with_capacity(merges.len()); for sha in &merges { rows.push((&sha[..7.min(sha.len())], subject_of(sha)?)); } let lines = two_column_listing(rows); bail!( "{base}..HEAD contains {} merge commit(s):\n{lines}\ a stack is cut out of a straight line of commits, and a merge has no \ patch of its own\n\ linearize with `git rebase {base}`, then rerun", merges.len(), )}
/// Every refusal that a plan alone decides, in one place so they can run/// before the branch is rewritten rather than after it.////// An empty patch, a message naming an image that is not on the disk, two/// commits claiming one change-id, and a cut that leaves a single member:/// all four are answerable from the commits as they stand. Deciding them/// after `--add-change-ids` meant refusing with the branch already moved —/// new shas, no pull requests, and the original mistake now sitting on/// commits nobody wrote.fn refuse_unplannable(planned: &[Planned], commits: usize, base: &str) -> Result<()> { if planned.len() == 1 { bail!( "the marks cut {base}..HEAD into one pull request of {commits} commit(s), and \ a stack is two or more\n\ `atgc pr create` files this as the one pull request it is, or \ `atgc stack mark <rev>` cuts it again" ); } refuse_empty_patches(planned)?; // Every commit here publishes, so a bad image reference refuses the // stack now, exactly as before the scan became deferrable. for p in planned { if let Err(e) = &p.images { bail!("{e:#}"); } } if let Some((id, shas)) = duplicate_change_id(planned) { bail!( "commits {} all carry the change-id {id}\n\ a change-id names one pull across rewrites; a cherry-pick copying the \ trailer is the usual cause\n\ amend all but one with a fresh Change-Id, then rerun", shas.join(" and "), ); } Ok(())}
/// Whether a format-patch changes anything at all. An empty commit's patch/// is headers with no `diff --git` in it.fn patch_has_diff(patch: &str) -> bool { patch.contains("\ndiff --git ")}
/// The knot refuses to merge an empty patch — its merge check reports a/// conflict with no files in it — so a stack carrying one could never land,/// and it is refused at the door instead of at the end.fn refuse_empty_patches(planned: &[Planned]) -> Result<()> { let empty: Vec<&Planned> = planned .iter() .filter(|p| !patch_has_diff(&p.patch)) .collect(); if empty.is_empty() { return Ok(()); } let lines = two_column_listing(empty.iter().map(|p| (p.short(), &p.subject))); bail!( "{} commit(s) in the range change nothing:\n{lines}\ the knot refuses to merge an empty patch\n\ drop them or give them content, then rerun", empty.len(), )}
/// A patch reduced to what a round means: everything above the trailing/// signature block `git format-patch` appends (`-- ` then the git version),/// which travels with whichever git formatted it. Compared raw, a git/// upgrade, a second machine, or a round appended by Tangled's web resubmit/// made every member read "changed" — and the documented rerun-is-a-no-op/// recovery appended a spurious round per pull instead of doing nothing.fn comparable_patch(patch: &str) -> &str { match patch.rfind("\n-- \n") { Some(at) => &patch[..at], None => patch, }}
/// Two or more commits claiming one change-id, if any — with every claimant/// named, because the fix is amending all but one of them.fn duplicate_change_id(planned: &[Planned]) -> Option<(String, Vec<String>)> { let mut by_id: std::collections::HashMap<&str, Vec<String>> = std::collections::HashMap::new(); for p in planned { for (sha, id) in p.shas.iter().zip(&p.change_ids) { by_id .entry(id.as_str()) .or_default() .push(sha.chars().take(7).collect()); } } by_id .into_iter() .find(|(_, shas)| shas.len() > 1) .map(|(id, shas)| (id.to_string(), shas))}
/// The `Change-Id:` mail header of a round's patch, where stack identity is/// read from.fn change_id_header(patch: &str) -> Option<String> { let headers = patch.split("\n\n").next()?; headers .lines() .find_map(|l| l.strip_prefix("Change-Id: ").map(|v| v.trim().to_string()))}
/// Every `Change-Id:` header in a round's patch, bottom message first: one/// per commit the member carries.////// A member of one commit yields one, which is every stack written before/// members could hold more. A message with no header at all is skipped/// rather than positioned — the ids are used as a set of claims, and a/// placeholder in the list would be a claim on nothing.fn change_id_headers(patch: &str) -> Vec<String> { let offsets = gitpatch::message_offsets(patch); if offsets.is_empty() { return change_id_header(patch).into_iter().collect(); } offsets .iter() .enumerate() .filter_map(|(i, &start)| { let end = offsets.get(i + 1).copied().unwrap_or(patch.len()); change_id_header(&patch[start..end]) }) .collect()}
/// Cut the branch the way the existing records already cut it.////// Each member owns the change-ids its latest round carries; the commits/// answering to them mark out that member's *span* on the branch. A commit/// inside a span that no member claims — one written into the middle of a/// member since the last reconcile — joins the member whose span it fell/// in, which is the reading that keeps a member of several commits from/// silently splintering into new pulls. A commit outside every span becomes/// its own new member, exactly as a new commit always has.////// Two members whose spans overlap is the one shape this cannot represent:/// their commits are interleaved, and a member is a contiguous run. That is/// refused rather than guessed at, with the branch marks named as the way to say/// what was meant.fn groups_from_members(commits: &[String], old: &[OldMember]) -> Result<Groups> { let mut ids = Vec::with_capacity(commits.len()); for sha in commits { ids.push(gitpatch::change_id(sha)?); } groups_from_ids(&ids, old)}
/// [`groups_from_members`] with git already asked: the branch as a list of/// change-ids, bottom first, `None` where a commit carries none. Split out/// because the spans-and-overlaps reasoning is the whole of the rule and a/// test of it should not need a repository.fn groups_from_ids(ids: &[Option<String>], old: &[OldMember]) -> Result<Groups> { // Bottom-most and top-most commit each member still holds. let mut spans: Vec<(usize, usize, usize)> = Vec::new(); for (m, member) in old.iter().enumerate() { let mut hits = ids .iter() .enumerate() .filter(|(_, id)| id.as_deref().is_some_and(|id| member.owns(id))) .map(|(i, _)| i); if let Some(first) = hits.next() { let last = hits.next_back().unwrap_or(first); spans.push((first, last, m)); } } spans.sort_by_key(|&(first, _, _)| first); for pair in spans.windows(2) { let ((_, last, a), (first, _, b)) = (pair[0], pair[1]); if first <= last { bail!( "the commits of {} ({}) and {} ({}) are interleaved on the branch, and a \ pull request holds a run of commits, not a scattering\n\ reorder the branch so each pull's commits sit together, or say the cut \ outright by marking the cuts with local branches", old[a].title, old[a].rkey, old[b].title, old[b].rkey, ); } } let mut groups: Groups = Vec::new(); let mut i = 0; while i < ids.len() { match spans .iter() .find(|&&(first, last, _)| first <= i && i <= last) { Some(&(_, last, _)) => { groups.push((i..=last).collect()); i = last + 1; } None => { groups.push(vec![i]); i += 1; } } } Ok(groups)}
/// Read one member's record back into an [`OldMember`]: the latest round's/// patch comes off the author's PDS (a public blob read, bounded the way/// `pr diff` bounds it) because the patch header is the only place the/// member's change-id exists./// The account a member's record belongs to, and the PDS to read it from.////// **A member is read from its own author's PDS, never from the reader's.**/// A record lives in the repository its at-uri names, so the account doing/// the reading is not the account that has the bytes — and the round patches/// this fetches are blobs, which only that PDS serves.////// This was two rules. `stack view` resolved per member and was right;/// `stack merge` passed its own PDS and account for every member and was/// wrong — while being, by design, *the* command a repo owner uses to land a/// contributor's stack. It asked its own PDS for a contributor's patch blob,/// got a 404 and reported it as the contributor's patch being gone.async fn member_home(item: &serde_json::Value) -> Result<(String, String)> { let uri = item["uri"].as_str().unwrap_or_default(); let did = crate::model::record::authority_of(uri) .with_context(|| format!("{uri} is not an at-uri, so nothing says whose record it is"))? .to_string(); let pds = crate::clients::atproto::did::pds_or_fail(&did).await?; Ok((did, pds))}
async fn old_member(item: &serde_json::Value, state: &str) -> Result<OldMember> { let (did, pds) = member_home(item).await?; let (did, pds) = (did.as_str(), pds.as_str()); let uri = item["uri"].as_str().unwrap_or_default().to_string(); let rkey = uri.rsplit('/').next().unwrap_or_default().to_string(); let value = &item["value"]; let title = value["title"].as_str().unwrap_or("(untitled)").to_string(); let rounds = value["rounds"].as_array().map(|r| r.len()).unwrap_or(0); let body = value["body"].as_str().map(str::to_string); let latest_patch = latest_round_patch(pds, did, value, &format!("{title} ({rkey})")).await?; Ok(OldMember { change_ids: change_id_headers(&latest_patch), dependent_on: value["dependentOn"].as_str().map(str::to_string), body, latest_patch, uri, rkey, title, state: state.to_string(), rounds, })}
/// How many members are read back at once.////// Each read is a `getRecord` and a blob download — the latest round's/// patch, which is where a member's change-id lives and whose bytes decide/// whether anything changed. `resubmit` and `merge` both did one per loop/// iteration with an `.await` on it, so a ten-member stack was ten round/// trips end to end before either could plan anything.////// Bounded, for `images.rs`'s reason: a stack should not open as many/// simultaneous streams against one PDS as it happens to have members./// *Ordered* — `buffered`, not `buffer_unordered` — because both callers/// build a `Vec` whose index is the member's position in the chain, and the/// chain's order is the whole subject of these commands.const MEMBER_READ_CONCURRENCY: usize = 4;
/// Read every member of `members` back, in order.////// `state_of` is a pure lookup against the listing already in hand, so it is/// evaluated per member here rather than being another thing to thread/// through./// The order this returns is the chain's, and/// `reordering_the_branch_relinks_the_chain_onto_the_new_order` in/// `tests/stack_flows.rs` is what holds it: its three members all read at/// once at this concurrency, and it asserts the exact `dependentOn` chain/// that comes out.async fn read_members<'a>( members: impl Iterator<Item = (&'a serde_json::Value, String)>,) -> Result<Vec<OldMember>> { use futures_util::stream::{self, StreamExt, TryStreamExt}; let members: Vec<_> = members.collect(); crate::logging::debug::log(format!( "stack: reading {} member(s) back, {MEMBER_READ_CONCURRENCY} at a time", members.len() )); stream::iter( members .into_iter() .map(|(member, state)| async move { old_member(member, &state).await }), ) .buffered(MEMBER_READ_CONCURRENCY) .try_collect() .await}
/// Reconcile the stack's records with the rewritten branch.////// Tangled's own semantics, so a stack is interchangeable between atgc and/// the web: matched commits keep their records (a changed patch appends a/// round), new commits create records, vanished commits delete theirs under/// `--prune`, and the whole `dependentOn` chain is rewritten to the new/// order — all in one applyWrites. Rerunning it against an unchanged branch/// is a no-op, which is also the recovery story for a partial failure:/// whatever landed is recognized as done and only the remainder is sent.pub(crate) async fn resubmit(args: ResubmitArgs) -> Result<()> { let json = args.json; let mut rewritten = None; let report = note_rewrite( resubmit_inner(args, &mut rewritten).await, rewritten.as_ref(), )?; match json { true => crate::term::jsonout::emit(&report), false => Ok(()), }}
/// The reconcile itself, which says everything it has to say on stdout as it/// goes and hands the report back rather than printing it.////// Returning it is what lets `stack sync` run this as its second half and/// still obey rule 1 of `--json`: one value on stdout, so a command that/// rebases *and* reconciles emits one object describing both, not two.async fn resubmit_inner( args: ResubmitArgs, rewritten: &mut Option<gitpatch::Rewrite>,) -> Result<StackResubmittedJson> { crate::term::jsonout::init(args.json); let selection = crate::config::account::select().await?; selection.announce(); let me = selection.did.clone();
let branch = git::current_branch()?; let remote_url = git::remote_url(&args.remote)?; let repo = resolve::repo_ref(&remote_url).await?;
// The stack as recorded: the complete listing, entered through the // acting account's own pull for the branch, refused unless seen whole // and wholly owned — the preamble every stack write shares, in one // place now instead of three drifting copies. let rows = super::complete_rows(listing::Source::EVERY, &repo.did, "a reconcile").await?; let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect(); let chain = super::chain_for_branch( &items, &branch, &me, &super::closed_uris(&rows), listing::Source::EVERY, super::Whose::Mine, "a stack starts with `atgc stack create`; resubmit reconciles one that exists", "`atgc pr resubmit` appends a round to it; `atgc stack create` turns it into \ a stack, adopting it as the bottom member", )?; let target_branch = crate::model::pull::target_branch_of_row(chain.top())?;
// The branch as it stands now. let base = format!("{}/{}", args.remote, target_branch); if !git::ref_exists(&base) { git::fetch(&args.remote, &target_branch)?; } let commits = gitpatch::commits_since(&base)?; if commits.is_empty() { bail!("no commits between {base} and HEAD; nothing to reconcile the stack with"); } // A reconcile against a target that has moved is not wrong — the records // describe the branch, and the branch is what it is — but the patches it // writes are patches against an old base, and every one of them is a // conflict waiting at merge time. Said once, here, because this is the // moment somebody is looking at the stack and could fix it in one // command. if !args.json && !git::is_ancestor(&base, "HEAD") { crate::term::say::note!( Git, "{base} has moved since this branch left it, so these rounds carry patches \ against the old base\n\ `atgc stack sync` rebases and reconciles in one go" ); } refuse_merges(&base)?;
// Everything that does not depend on the branch is settled before the // branch can move: the session, the PDS, and every member read back with // its patch. All three used to sit below `ensure_change_ids`, so any of // them failing — an expired session most ordinarily — left a rewritten // branch and an untouched stack. None of them has anything to say about // the commits, so none of them had a reason to be down there. let agent = match args.dry_run { true => None, false => Some(auth::agent_for_did(&me).await?), }; // Read every member back, latest patch included: the patch header is // where a member's change-id lives, and the bytes are what decides // "changed" — identical bytes append no round, which is what makes a // rerun a no-op. let pds = crate::clients::atproto::did::pds_or_fail(&me).await?; // A member showing `?` here is one whose state genuinely cannot be // settled: `repo_rows` already resolves the fresh-stack case — no // status record anywhere, but the acting account owns the repo and // authored the pull, so its own PDS was a complete answer — to open. let old: Vec<OldMember> = read_members(chain.members.iter().map(|member| { let uri = member["uri"].as_str().unwrap_or_default(); (*member, super::state_of(&rows, uri)) })) .await?;
// The trap this pair of commands is most dangerous in, asked before the // rewrite rather than after it. A commit with no change-id gets // `I<its own sha>`, which by construction matches no pull that exists — // so a branch rebuilt without its trailers makes every open member // unmatched, and the reconcile's honest answer to that is to name // `--prune`, which deletes those records and the review comments on // them. Run afterwards, that advice arrived with the old shas already // gone; run here, nothing has moved and the commits can still be found. if args.add_change_ids && let Some(after) = change_ids_after_rewrite(&commits)? { refuse_orphaned_by_rewrite(&old, &after, args.prune)?; }
let rewrite_pending = ensure_change_ids( &base, &commits, args.add_change_ids, args.dry_run, rewritten, )?; if rewrite_pending { bail!( "cannot plan a reconcile before the rewrite adds those change-ids; rerun \ without --dry-run, or add the ids first" ); } let commits = gitpatch::commits_since(&base)?; // How the branch is cut into members. The branches pointing into the // range are the authority when there are any: they are what `create` // cut on, they are what a person moves when they mean to re-cut, and // `git rebase --update-refs` keeps them on the right commits. With none // — or with --per-commit — the records themselves say where the cuts // are, which is what makes a grouped stack reconcile with no flags at // all even in a checkout that has lost the branches. let marks = match args.per_commit { true => Vec::new(), false => { let (marks, stranded) = super::marks::positions_and_stranded(&branch, &commits); warn_stranded_marks(&stranded); marks } }; let cut = match (args.per_commit, marks.is_empty()) { (true, _) => Cut::per_commit(commits.len()), (false, false) => cut_at_marks(commits.len(), &marks), (false, true) => { let groups = groups_from_members(&commits, &old)?; Cut { labels: vec![None; groups.len()], groups, } } }; let planned = plan_groups(&me, &commits, &cut.groups, false)?; refuse_empty_patches(&planned)?;
let plan = reconcile(&old, &planned, args.prune)?;
// Say the plan, then do it (or stop). let total = plan.chain.len(); if !args.json { println!("target: {} branch {target_branch}", repo.linked()); println!("source: branch {branch}"); for (i, slot) in plan.chain.iter().enumerate().rev() { let line = match slot { Slot::Update { index, member } => format!( "update {}{} (round {}, {})", old[*member].title, label_of(&cut, *index), old[*member].rounds + 1, describe_commits(&planned[*index]), ), Slot::Keep { member, relink: true, } => format!("relink {} (chain order moved)", old[*member].title), Slot::Keep { member, relink: false, } => format!("keep {}", old[*member].title), Slot::Frozen { member } => { format!("merged {} (never touched)", old[*member].title) } Slot::Add { index } => format!( "add {}{} ({})", planned[*index].subject, label_of(&cut, *index), describe_commits(&planned[*index]), ), }; println!(" {}/{total} {line}", i + 1); // An update is the only fate that would rewrite a description, // so it is the only one that can decline to. if let Slot::Update { member, .. } = slot && !old[*member].body_is_its_commits() { println!(" body kept (edited since its last round)"); } // The bodies that will be (re)written are the ones whose images // matter to the plan; a kept member's body is not touched. if let Slot::Update { index, .. } | Slot::Add { index } = slot { for img in planned[*index] .images .as_ref() .map(|i| i.describe()) .unwrap_or_default() { println!(" image: {img}"); } } } for &i in &plan.drops { println!(" drop {} ({})", old[i].title, old[i].rkey); } for &i in &plan.retired { println!( " retire {} ({}; closed, unlinked from the chain)", old[i].title, old[i].rkey ); } if let Some(anchor) = plan.anchor { println!( " below {} (merged; stays the chain's base)", old[anchor].title ); } } let nothing_to_send = plan.drops.is_empty() // A retired member whose link is still standing is work: see the // unlinking loop below for why it cannot be left there. Retiring the // *top* of a stack relinks nothing, so without this the one op the // batch owed would never be sent. && plan.retired.iter().all(|&i| old[i].dependent_on.is_none()) && plan .chain .iter() .all(|s| matches!(s, Slot::Keep { relink: false, .. } | Slot::Frozen { .. }));
let mut report = StackResubmittedJson { dry_run: args.dry_run, pushed: false, changed: !nothing_to_send, repo_did: repo.did.clone(), target_branch: target_branch.clone(), source_branch: branch.clone(), total, // Top first, as the text listing above prints it. members: plan .chain .iter() .enumerate() .rev() .map(|(i, slot)| reconciled_member_json(i + 1, slot, &old, &planned)) .collect(), drops: plan .drops .iter() .map(|&i| DroppedJson { rkey: old[i].rkey.clone(), uri: old[i].uri.clone(), title: old[i].title.clone(), }) .collect(), retired: plan .retired .iter() .map(|&i| DroppedJson { rkey: old[i].rkey.clone(), uri: old[i].uri.clone(), title: old[i].title.clone(), }) .collect(), anchor: plan.anchor.map(|i| DroppedJson { rkey: old[i].rkey.clone(), uri: old[i].uri.clone(), title: old[i].title.clone(), }), wrote: Vec::new(), url: format!("{}/pulls", repo.web_url), };
// The claim every member of this stack already makes, kept true. A // reconcile is a rewritten branch by definition, and one that leaves the // branch behind writes rounds describing commits the knot does not have: // `source: {branch}` becomes the untruth `stack create` refuses to tell // in the first place, and the appview's own resubmit check compares the // top member against a branch head that never moved. // // Before the no-op return rather than after it, because a stack whose // records already match an unpushed branch is precisely the state this // has to be able to repair — and when the remote is already there, this // costs one `ls-remote` and sends nothing. if !args.dry_run { // What the last round published, read off its own patch: the sha to // lease against, and never the local remote-tracking ref, which a // fetch can advance onto the commits worth protecting. let expected = old.last().and_then(|m| gitpatch::head_sha(&m.latest_patch)); report.pushed = crate::cmd::publish_branch( &args.remote, &branch, commits.last().map(String::as_str).unwrap_or_default(), expected.as_deref(), &format!( "This stack records `source: {branch}`, so a reconcile has to republish \n\ it; the records would otherwise describe commits the knot does not have." ), )?; }
if nothing_to_send { if !args.json { println!("the stack already matches the branch; nothing to send"); } return Ok(report); } if args.dry_run { if !args.json { println!("dry run; nothing sent"); } return Ok(report); }
let agent = agent.expect("a run that is not a dry run resumed its session above"); // The commit CID is read *before* the member records are, so the // precondition covers everything about to be re-read and rewritten: // any write that lands between here and the batch — a web resubmit, // a second atgc — turns the batch into InvalidSwap instead of a silent // clobber. pr resubmit has guarded exactly this with swapRecord since // it existed; the batch path was sending None. let swap_commit = crate::clients::atproto::pds::latest_commit(&pds, &me).await?; let mut ops: Vec<Op> = Vec::new(); // Only the members being minted need their patch bytes up front: // `append_round_op` compresses its own, and a kept or frozen member has // nothing to send. Building the whole vector here would compress every // unchanged member of the stack on every reconcile. let mut gzipped: Vec<Vec<u8>> = vec![Vec::new(); planned.len()]; for slot in &plan.chain { if let Slot::Add { index } = slot { gzipped[*index] = gzip(&planned[*index].patch)?; } }
let (walked, _uris) = chain_ops( &agent, Some(&pds), &me, &repo.did, &target_branch, &branch, &plan.chain, &old, &planned, &mut gzipped, plan.anchor.map(|i| old[i].uri.clone()), ) .await?; ops.extend(walked);
for &i in &plan.drops { ops.push(Op::Delete { nsid: PULL_NSID, rkey: Key::any_owned(&old[i].rkey) .map_err(|e| anyhow::anyhow!("bad record key {}: {e}", old[i].rkey))?, }); } // A retired member keeps everything about itself except its place in the // chain. The link is the one part of the record that has just stopped // being true: the member above it was relinked past it a few lines up, // so leaving it in place gives one parent two dependents. // // That is a fork, and a fork is not a cosmetic untidiness in somebody // else's copy. `chain_containing` refuses to order one, which takes // `stack view`, the next `resubmit`, `sync` and `merge` with it — the // stack this command just wrote becomes one no stack command will read. // And the appview does not refuse: its own walk (`appview/db/pulls.go`, // `GetStack`) asks for *the* pull depending on a given one and skips // only abandoned records, so on tangled.org a fork silently resolves to // whichever branch the query happens to return, and the live member // above can disappear out of the stack view. // // Unlinking is a whole write of the record, so it costs one op in a // batch that is already atomic — no extra round trip, and nothing about // the close survives less well for it. The rounds, the title, the body // and the comments hanging off the record are all untouched. for &i in &plan.retired { let m = &old[i]; if m.dependent_on.is_none() { continue; } ops.push(relink_op(&pds, &me, &m.rkey, None).await?); }
// The last thing before anything leaves the machine: read the chain // these ops would produce, and refuse it if the reader could not. // `super::refuse_new_damage` owns every rule about a well-formed chain, // so a verb added later gets them without knowing they exist. super::refuse_new_damage(&items, &me, &ops, &super::closed_uris(&rows))?;
let written = crate::clients::atproto::record::batch(&agent, "stack", &me, ops, Some(&swap_commit)) .await?; if args.json { report.wrote = written; return Ok(report); } for uri in &written { println!("wrote {uri}"); } println!( "view: {}", crate::term::hyperlink::url(&format!("{}/pulls", repo.web_url)) ); Ok(report)}
// ---------------------------------------------------------------------------// `stack rebase`// ---------------------------------------------------------------------------
#[derive(clap::Args, Debug)]pub(crate) struct RebaseArgs { /// Target branch to rebase onto (defaults to the remote's default branch) #[arg(long)] pub target: Option<String>, /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// Say what would be replayed without touching the branch #[arg(long)] pub dry_run: bool, /// Print one JSON object instead of the summary lines #[arg(long)] pub json: bool,}
#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct RebasedJson { pub dry_run: bool, /// `false` when the branch was already on top of its target: nothing ran. pub rebased: bool, pub branch: String, pub base: String, /// The tip before, and after. Equal on a no-op, and `was` is the sha to /// `git reset --hard` back to. pub was: String, pub now: String, pub commits: usize, /// The marks that travel with this replay, by branch name. Populated on /// a dry run too, since naming what would move is most of what a dry run /// is for; `atgc stack mark` is where their new positions are read back. pub marks: Vec<String>,}
/// Rebase the stack onto its target, carrying the marks.////// The one command here that runs `git rebase`, and it exists for one reason:/// `--update-refs`. A stack is cut at branch marks, and a plain rebase leaves/// every one of them on a commit the branch no longer has — so the cut/// silently disappears and the next reconcile sees an uncut branch. Telling/// people to remember a flag is not a design; running the rebase is.////// Nothing is sent. This moves the branch and stops, because a reconcile/// wants a branch that is finished moving, and a rebase that stops on a/// conflict is a branch that is not.pub(crate) async fn rebase(args: RebaseArgs) -> Result<()> { crate::term::jsonout::init(args.json); let json = args.json; let report = rebase_run(&args).await?; match json { true => crate::term::jsonout::emit(&report), false => Ok(()), }}
/// The rebase itself, printing as it goes and handing the report back — the/// same split as [`resubmit_inner`], and for the same reason: `stack sync`/// runs this as its first half and emits one object for both.async fn rebase_run(args: &RebaseArgs) -> Result<RebasedJson> { // Asked before anything else, because a rebase in progress is a detached // HEAD: every other question here — which branch, is the tree clean — // answers with something misleading first. "check out a branch" is the // one piece of advice that would throw away the conflict work somebody // is in the middle of. if git::rebase_in_progress() { bail!( "a rebase is already in progress here\n\ finish it with `git rebase --continue`, or drop it with `git rebase --abort`" ); } let branch = git::current_branch()?; let target = match &args.target { Some(t) => t.clone(), None => git::remote_default_branch(&args.remote).unwrap_or_else(|| "main".to_string()), }; let base = format!("{}/{}", args.remote, target);
// Refused before the fetch, so a dirty tree costs nothing and says the // same thing whether or not the network is there. if !gitpatch::working_tree_clean()? { bail!( "the working tree has uncommitted changes\n\ commit or stash them first; a rebase needs a clean tree" ); } // Fetched, but not required. Rebasing onto a target that is a minute // stale is the ordinary case and still worth doing; refusing to replay // anything because a host is down would make the one command that // rewrites the branch the one command that needs the network most. A // fetch that fails with no local copy of the target is a different // thing, and does stop it. if let Err(e) = git::fetch(&args.remote, &target) { if !git::ref_exists(&base) { return Err(e).context(format!( "{base} is not in this checkout, so there is nothing to rebase onto" )); } crate::logging::debug::dump_err("stack rebase: fetch failed", &e); crate::term::say::warning!( Git, "could not fetch {} {target}: replaying onto the {base} already here, which \ may be behind", args.remote, ); } let was = git::git_in(Path::new("."), &["rev-parse", "HEAD"])? .trim() .to_string(); let commits = gitpatch::commits_since(&base)?; let marks = super::marks::positions(&branch, &commits); let up_to_date = git::is_ancestor(&base, "HEAD") && git::git_in(Path::new("."), &["rev-parse", &base])?.trim() == git::git_in(Path::new("."), &["merge-base", &base, "HEAD"])?.trim();
if !args.json { println!("branch: {branch}"); println!("onto: {base}"); println!( "replay: {} commit(s), {} mark(s) travelling with them", commits.len(), marks.len() ); for (at, name) in &marks { println!(" {}/{} {name}", at + 1, commits.len()); } }
let mut report = RebasedJson { dry_run: args.dry_run, rebased: false, branch: branch.clone(), base: base.clone(), was: was.clone(), now: was.clone(), commits: commits.len(), marks: marks.iter().map(|(_, name)| name.clone()).collect(), };
if up_to_date { if !args.json { println!("already on top of {base}; nothing to replay"); } return Ok(report); } if args.dry_run { if !args.json { println!("dry run; the branch has not moved"); } return Ok(report); }
// `--update-refs` is the whole point; `--no-autostash` because the clean // tree was already required above, and an autostash that fails to reapply // after a conflict is a worse mess than the refusal. git::rebase_update_refs(&base)?;
report.now = git::git_in(Path::new("."), &["rev-parse", "HEAD"])? .trim() .to_string(); report.rebased = true; if !args.json { println!( "rebased: {} → {} (`git reset --hard {}` puts it back)", &was[..7.min(was.len())], &report.now[..7.min(report.now.len())], &was[..7.min(was.len())], ); println!("next: atgc stack resubmit"); } Ok(report)}
#[derive(clap::Args, Debug)]pub(crate) struct SyncArgs { /// Target branch to rebase onto (defaults to the remote's default branch) #[arg(long)] pub target: Option<String>, /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// Delete the records of pulls whose commits left the branch #[arg(long)] pub prune: bool, /// Rewrite base..HEAD to add Change-Id trailers to commits lacking one #[arg(long)] pub add_change_ids: bool, /// Say what both halves would do without moving the branch or writing #[arg(long)] pub dry_run: bool, /// Print one JSON object describing both halves instead of the summary lines #[arg(long)] pub json: bool,}
/// What `stack sync` did: the rebase, then the reconcile.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct SyncedJson { pub rebase: RebasedJson, pub reconcile: StackResubmittedJson,}
/// Catch the branch up with its target and reconcile the stack: the two/// halves of a review cycle, in the order they have to happen.////// The halves stay available on their own — a conflict during the rebase/// leaves work to do before any reconcile makes sense, and an amend that/// needs no rebase needs no fetch either — but neither is the verb to reach/// for by default. `gh stack sync` and Graphite's `gt sync` pair the same/// two behind one word.////// Stops after the rebase if the rebase stopped. A reconcile planned against/// a half-rebased branch would be a plan for a branch that does not exist/// yet, and the records it wrote would have to be undone by hand.pub(crate) async fn sync(args: SyncArgs) -> Result<()> { crate::term::jsonout::init(args.json);
let rebase = rebase_run(&RebaseArgs { target: args.target.clone(), remote: args.remote.clone(), dry_run: args.dry_run, json: args.json, }) .await?;
if !args.json { println!(); } let mut rewritten = None; let reconcile = note_rewrite( resubmit_inner( ResubmitArgs { remote: args.remote, add_change_ids: args.add_change_ids, prune: args.prune, per_commit: false, dry_run: args.dry_run, json: args.json, }, &mut rewritten, ) .await, rewritten.as_ref(), )?;
match args.json { true => crate::term::jsonout::emit(&SyncedJson { rebase, reconcile }), false => Ok(()), }}
// ---------------------------------------------------------------------------// `stack link`// ---------------------------------------------------------------------------
#[derive(clap::Args, Debug)]pub(crate) struct LinkArgs { /// The pulls to chain, bottom first: numbers, record keys or at-uris #[arg(required = true, num_args = 2..)] pub pulls: Vec<String>, /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// Say what would be chained without writing anything #[arg(long)] pub dry_run: bool, /// Print one JSON object describing the chain instead of the summary lines #[arg(long)] pub json: bool,}
/// One member of `stack link --json`, bottom first.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct LinkedJson { /// 1-based from the bottom, the order they will be reviewed and merged in. pub position: usize, pub uri: String, pub rkey: String, pub title: String, /// The at-uri this member is made to depend on, `null` for the bottom. pub dependent_on: Option<String>, /// False when the record already said exactly this, so nothing was /// written for it. pub changed: bool,}
#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct StackLinkedJson { pub dry_run: bool, /// `false` when every record already named the right parent. pub changed: bool, pub repo_did: String, pub target_branch: String, /// Bottom first, the order the chain is written in. pub members: Vec<LinkedJson>, pub wrote: Vec<String>, pub url: String,}
#[derive(clap::Args, Debug)]pub(crate) struct UnlinkArgs { /// The pulls to take out of their chain: numbers, record keys or at-uris #[arg(required = true)] pub pulls: Vec<String>, /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// Say what would be unlinked without writing anything #[arg(long)] pub dry_run: bool, /// Print one JSON object describing what was unlinked #[arg(long)] pub json: bool,}
/// One pull `stack unlink` took out of a chain.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct UnlinkedJson { pub uri: String, pub rkey: String, pub title: String, /// What it used to depend on, or `null` if it was the bottom. pub was_on: Option<String>, /// The pull that depended on it and now depends on `was_on`, or `null` /// if nothing did. pub relinked: Option<String>, /// A pull that depended on it and belongs to somebody else, so it was /// left where it is: `null` when there is none. /// /// It cannot be relinked from here — the record is in that account's /// repository — and the unlink still happens, which leaves their pull /// depending on a member that no longer depends on anything. That is a /// well-formed chain, just a shorter one than they had. #[serde(skip_serializing_if = "Option::is_none")] pub left_attached: Option<String>,}
#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct StackUnlinkedJson { pub dry_run: bool, /// `false` when nothing named was in a chain to begin with. pub changed: bool, pub repo_did: String, pub unlinked: Vec<UnlinkedJson>, pub wrote: Vec<String>, pub url: String,}
/// Take pulls out of a chain, closing the gap behind them.////// The inverse of [`link`], and the half that was missing. `link` writes/// `dependentOn` and nothing removed it: `stack resubmit` clears one only as/// a side effect of retiring a *closed* member, and it cannot touch a chain/// whose patches carry no change-id — which is every pull `pr create` ever/// wrote. So a chain linked in the wrong order, or onto the wrong pull, had/// exactly one exit: a hand-written `com.atproto.repo.putRecord`. That is/// not an escape hatch, it is the absence of one.////// **Closing the gap is the whole operation.** Clearing one field would/// leave whatever depended on the named pull pointing at a record that is no/// longer in the chain, which is a break rather than a removal — so each/// pull's dependent inherits its parent, exactly as retiring a member does./// Naming every member is how a chain is dissolved; there is no separate/// verb for it, because it is the same operation applied throughout.////// One `applyWrites`, for [`link`]'s reason: written a record at a time, a/// chain passes through a state the appview refuses at ingest.pub(crate) async fn unlink(args: UnlinkArgs) -> Result<()> { crate::term::jsonout::init(args.json); let selection = crate::config::account::select().await?; selection.announce(); let me = selection.did.clone();
let remote_url = git::remote_url(&args.remote)?; let repo = resolve::repo_ref(&remote_url).await?;
let mut named = Vec::with_capacity(args.pulls.len()); for reference in &args.pulls { named .push(crate::cmd::pr::review::resolve_pull(Some(reference), None, &args.remote).await?); } let mut seen = std::collections::HashSet::new(); for m in &named { if !seen.insert(m.uri.clone()) { bail!("{} is named twice", m.rkey); } // Same rule as `link`: the chain is a field on each record, and only // its author may write it. A pull of somebody else's can sit in a // stack, and taking it out is their write to make. if m.did != me { bail!( "{} belongs to {}, and this would rewrite it\n\ only the account that authored a pull can unchain it", m.rkey, m.did ); } }
let rows = super::complete_rows(listing::Source::EVERY, &repo.did, "an unlink").await?; let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect(); let parent_of = |uri: &str| -> Option<String> { items .iter() .find(|i| i["uri"].as_str() == Some(uri)) .and_then(|i| i["value"]["dependentOn"].as_str()) .map(str::to_string) };
// What to write, and what it should say. A pull that depends on one being // unlinked inherits that pull's own parent, so the chain closes rather // than breaking. Resolved against the records as they are *now*, before // anything is written, so unlinking several members of one chain at once // is a single consistent answer rather than a sequence of edits. let mut new_parent: std::collections::BTreeMap<String, Option<String>> = std::collections::BTreeMap::new(); let named_uris: std::collections::HashSet<&str> = named.iter().map(|m| m.uri.as_str()).collect(); let mut report_rows = Vec::new(); for m in &named { let was_on = parent_of(&m.uri); // Walk down past anything else being unlinked in the same call, so a // dependent inherits the first member that is staying. let mut inherits = was_on.clone(); while let Some(p) = inherits.clone() { if !named_uris.contains(p.as_str()) { break; } inherits = parent_of(&p); } let dependent = items .iter() .filter(|i| i["value"]["dependentOn"].as_str() == Some(m.uri.as_str())) .filter_map(|i| i["uri"].as_str()) .find(|uri| !named_uris.contains(uri)) .map(str::to_string); // **Only a record in this account's repository can be relinked.** // The listing is `Source::EVERY`, so the pull sitting on this one may // be somebody else's — and the relink used to be planned for it // anyway, which sent a `getRecord` for a stranger's record key // against *our* DID and died with "no pull record 3x in did:plc:me". // The unlink still stands; what cannot happen is closing the chain // over a record we may not write. let (dependent, left_attached) = match dependent { Some(uri) if crate::model::record::is_authored_by(&uri, &me) => (Some(uri), None), Some(uri) => (None, Some(uri)), None => (None, None), }; if let Some(dependent) = &dependent { new_parent.insert(dependent.clone(), inherits.clone()); } new_parent.insert(m.uri.clone(), None); report_rows.push(UnlinkedJson { uri: m.uri.clone(), rkey: m.rkey.clone(), title: m.value["title"] .as_str() .unwrap_or("(untitled)") .to_string(), was_on: was_on.clone(), relinked: dependent, left_attached, }); }
// Only the records whose field actually changes. new_parent.retain(|uri, parent| parent_of(uri) != *parent); let changed = !new_parent.is_empty();
let mut report = StackUnlinkedJson { dry_run: args.dry_run, changed, repo_did: repo.did.clone(), unlinked: report_rows, wrote: Vec::new(), url: format!("{}/pulls", repo.web_url), };
if !args.json { for row in &report.unlinked { let was = match &row.was_on { Some(uri) => uri.rsplit('/').next().unwrap_or("?").to_string(), None => "nothing (it was the bottom)".to_string(), }; println!(" unlink {} ({}; was on {was})", row.title, row.rkey); if let Some(dependent) = &row.relinked { println!( " {} now depends on what it did", dependent.rsplit('/').next().unwrap_or("?") ); } if let Some(theirs) = &row.left_attached { // Said rather than silently done: their pull is still stacked // on this one, and only they can move it. crate::term::say::warning!( Pds, "{theirs} depends on this and is not yours, so it stays where it is\n\ their pull is left stacked on a member that now depends on nothing; \ ask them to resubmit, or relink it through the web" ); } } if !changed { println!("nothing named is in a chain; nothing to write"); } } if !changed || args.dry_run { if args.json { return crate::term::jsonout::emit(&report); } if args.dry_run { println!("dry run; nothing sent"); } return Ok(()); }
let agent = auth::agent_for_did(&me).await?; let pds = crate::clients::atproto::did::pds_or_fail(&me).await?; let swap_commit = crate::clients::atproto::pds::latest_commit(&pds, &me).await?; let mut ops: Vec<Op> = Vec::new(); for (uri, parent) in &new_parent { let rkey = uri.rsplit('/').next().unwrap_or_default().to_string(); ops.push(relink_op(&pds, &me, &rkey, parent.as_deref()).await?); } // The last thing before anything leaves the machine: read the chain // these ops would produce, and refuse it if the reader could not. // `super::refuse_new_damage` owns every rule about a well-formed chain, // so a verb added later gets them without knowing they exist. super::refuse_new_damage(&items, &me, &ops, &super::closed_uris(&rows))?;
let written = crate::clients::atproto::record::batch(&agent, "stack", &me, ops, Some(&swap_commit)) .await?; if args.json { report.wrote = written; return crate::term::jsonout::emit(&report); } for uri in &written { println!("wrote {uri}"); } println!( "view: {}", crate::term::hyperlink::url(&format!("{}/pulls", repo.web_url)) ); Ok(())}
/// Chain pull requests that already exist into a stack.////// The gap this fills is the one every other stack verb assumes away: `stack/// create` opens a chain from a branch, and until now nothing could say "these/// pulls, in this order, are a stack". Rebuilding them was the only route —/// cherry-pick onto one branch, open new pulls, close the originals — which/// throws away each pull's number, its rounds and its review comments to/// express an ordering that is one field per record.////// That field is `dependentOn`, and the whole of this command is writing it,/// bottom-up, in a single `applyWrites`. Atomic because the appview rejects a/// would-be DAG at ingest: written one at a time, a chain passes through a/// state where two pulls name the same parent, and the ingest that sees it/// refuses the lot.pub(crate) async fn link(args: LinkArgs) -> Result<()> { crate::term::jsonout::init(args.json); let selection = crate::config::account::select().await?; selection.announce(); let me = selection.did.clone();
let remote_url = git::remote_url(&args.remote)?; let repo = resolve::repo_ref(&remote_url).await?;
// Resolved before anything else is read: a mistyped number should fail // while nothing has been asked of the network on its behalf. let mut members = Vec::with_capacity(args.pulls.len()); for reference in &args.pulls { members .push(crate::cmd::pr::review::resolve_pull(Some(reference), None, &args.remote).await?); }
let mut seen = std::collections::HashSet::new(); for m in &members { if !seen.insert(m.uri.clone()) { bail!( "{} is named twice; a pull can hold one place in a chain", m.rkey ); } // Only your own records are yours to write. A stranger's pull can sit // *in* a stack — the chain is a field on each record, not a shared // object — but somebody else has to write that field. if m.did != me { bail!( "{} belongs to {}, and this would rewrite it\n\ only the account that authored a pull can chain it", m.rkey, m.did ); } }
// One repo and one target, because a stack lands bottom-up onto a single // branch: two targets is two stacks, and the merge would take the wrong // half of one of them. let target_branch = crate::model::pull::target_branch(&members[0].value, &members[0].uri)?; for m in &members { let repo_did = m.value["target"]["repo"] .as_str() .or_else(|| m.value["target"]["repoDid"].as_str()) .unwrap_or_default(); if repo_did != repo.did { bail!( "{} targets {repo_did}, not the repo this checkout points at ({})", m.rkey, repo.did ); } let branch = crate::model::pull::target_branch(&m.value, &m.uri)?; if branch != target_branch { bail!( "{} targets branch {branch} and {} targets {target_branch}\n\ a stack lands bottom-up onto one branch", m.rkey, members[0].rkey, ); } }
// A merged or closed member has no place left in a chain: the chain is // the order the *remaining* ones land in, and a stack merges bottom-up // through open pulls, so one of these in the middle stops everything // above it. // // `?` is not refused. It is what `pr read` says when no status record it // can reach settles the pull, which is the ordinary answer for a pull on // somebody else's repo — refusing it would rule out the case this // command is most wanted for. Said out loud instead, below, because a // `dependentOn` written onto a pull that turns out to be merged is one // more `stack link` to undo, not a patch landed on the wrong tree. let rows = super::complete_rows(listing::Source::EVERY, &repo.did, "a link").await?; let mut unsettled = Vec::new(); for m in &members { match super::state_of(&rows, &m.uri).as_str() { state @ ("merged" | "closed") => bail!( "{} is {state}, so it has no place left in a chain\n\ link the ones that have not landed", m.rkey ), "?" => unsettled.push(m.rkey.clone()), _ => {} } }
// Already in some other chain is a fork waiting to happen: this would // point it somewhere new while whatever depends on it still points here. let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect(); let named: std::collections::HashSet<&str> = members.iter().map(|m| m.uri.as_str()).collect(); for m in &members { let dependents: Vec<&str> = items .iter() .filter(|i| i["value"]["dependentOn"].as_str() == Some(m.uri.as_str())) .filter_map(|i| i["uri"].as_str()) .filter(|uri| !named.contains(uri)) .collect(); if let Some(other) = dependents.first() { bail!( "{} already has {} depending on it, which this chain does not name\n\ linking it here would fork the chain, which the appview refuses at ingest", m.rkey, other.rsplit('/').next().unwrap_or(other), ); } }
// **Every member's patch has to hold its own commits and nobody else's.** // // A merge takes a member plus everything unmerged below it and applies // them as one series, so two members carrying the same commit apply it // twice: the knot answers "patch doesn't apply" and "file already // exists", which names a file rather than the cause and sends people // looking for a conflict that is not there. `stack create` gets this for // free by cutting one branch into ranges. `link` takes pulls that // already exist, and a pull opened by `pr create` records a patch of // `<target>..<its own branch>` — so two of those overlap whenever one // branch contains the other, *or* whenever they are the same branch. // // This is checked on the patches rather than on the branches because the // patch is what the merge applies. The branch check below is about // *order* and answers a different question; it used to be the only one, // and it accepts — indeed requires — the ancestry that guarantees the // overlap. Its own refusal advises "rebase {b} onto {a}", which is to // say it steered people into the shape that cannot merge. let pds = crate::clients::atproto::did::pds_or_fail(&me).await?; let mut patches: Vec<Vec<String>> = Vec::new(); for m in &members { let patch = latest_round_patch(&pds, &me, &m.value, &m.rkey).await?; patches.push(gitpatch::commit_shas(&patch)); } for (i, (a, b)) in members.iter().zip(&patches).enumerate() { for (other, others) in members.iter().zip(&patches).skip(i + 1) { if let Some(shared) = b.iter().find(|sha| others.contains(sha)) { bail!( "{} and {} both carry commit {}, so merging them as a series \ would apply it twice\n\ a stack's members hold separate commits: `atgc stack create` cuts \ one branch into members that do, and `link` can only chain pulls \ that already have them\n\ to stack this work, put the upper commits on the lower pull's own \ branch and run `atgc stack create` there: it adopts that pull as \ the bottom member, so its number, comments and rounds survive", a.rkey, other.rkey, &shared[..12.min(shared.len())], ); } } }
// Order, checked against git where git can answer it. A stack merges // bottom-up, so each member's commits have to sit on top of the one // below: a chain written in the wrong order lands a patch onto a tree // that has not had its parent applied. Only branch-based pulls whose // branches are in this checkout can be checked, and the rest are said // out loud rather than assumed. let mut unchecked = Vec::new(); for pair in members.windows(2) { let (lower, upper) = (&pair[0], &pair[1]); match ( source_branch_of(&lower.value), source_branch_of(&upper.value), ) { (Some(a), Some(b)) if git::ref_exists(&a) && git::ref_exists(&b) => { if a != b && !git::is_ancestor(&a, &b) { bail!( "{} ({a}) is not an ancestor of {} ({b}), so this order does \ not stack\n\ name them the other way round", lower.rkey, upper.rkey, ); } } _ => unchecked.push(upper.rkey.clone()), } }
let mut report = StackLinkedJson { dry_run: args.dry_run, changed: false, repo_did: repo.did.clone(), target_branch: target_branch.clone(), members: Vec::new(), wrote: Vec::new(), url: format!("{}/pulls", repo.web_url), }; let mut parent: Option<String> = None; for (i, m) in members.iter().enumerate() { let current = m.value["dependentOn"].as_str().map(str::to_string); let changed = current != parent; report.members.push(LinkedJson { position: i + 1, uri: m.uri.clone(), rkey: m.rkey.clone(), title: m.value["title"] .as_str() .unwrap_or("(untitled)") .to_string(), dependent_on: parent.clone(), changed, }); report.changed |= changed; parent = Some(m.uri.clone()); }
if !args.json { println!("target: {} branch {target_branch}", repo.linked()); let total = report.members.len(); for member in report.members.iter().rev() { let below = match (member.position, member.changed) { (1, _) => "the bottom, depending on nothing".to_string(), (_, true) => format!("on {}", report.members[member.position - 2].rkey), (_, false) => format!("on {} already", report.members[member.position - 2].rkey), }; println!( " {}/{total} {} {} — {below}", member.position, member.rkey, member.title ); } if !unchecked.is_empty() { crate::term::say::note!( Git, "the order of {} was taken on trust: no local branch to check it against", unchecked.join(", "), ); } if !unsettled.is_empty() { crate::term::say::note!( Index, "no reachable status settles {}; if one of them is already merged, \ link the rest instead", unsettled.join(", "), ); } }
if !report.changed { if args.json { return crate::term::jsonout::emit(&report); } println!("already chained in that order; nothing to send"); return Ok(()); } if args.dry_run { if args.json { return crate::term::jsonout::emit(&report); } println!("dry run; nothing sent"); return Ok(()); }
let agent = auth::agent_for_did(&me).await?; let pds = crate::clients::atproto::did::pds_or_fail(&me).await?; let swap_commit = crate::clients::atproto::pds::latest_commit(&pds, &me).await?;
let mut ops: Vec<Op> = Vec::new(); for (member, planned) in members.iter().zip(&report.members) { if !planned.changed { continue; } ops.push(relink_op(&pds, &me, &member.rkey, planned.dependent_on.as_deref()).await?); }
// The last thing before anything leaves the machine: read the chain // these ops would produce, and refuse it if the reader could not. // `super::refuse_new_damage` owns every rule about a well-formed chain, // so a verb added later gets them without knowing they exist. super::refuse_new_damage(&items, &me, &ops, &super::closed_uris(&rows))?;
let written = crate::clients::atproto::record::batch(&agent, "link", &me, ops, Some(&swap_commit)) .await?; if args.json { report.wrote = written; return crate::term::jsonout::emit(&report); } for uri in &written { println!("wrote {uri}"); } println!( "view: {}", crate::term::hyperlink::url(&format!("{}/pulls", repo.web_url)) ); Ok(())}
/// The branch a pull says it came from, as a local ref name.fn source_branch_of(value: &serde_json::Value) -> Option<String> { value["source"]["branch"] .as_str() .filter(|b| !b.is_empty()) .map(str::to_string)}
// ---------------------------------------------------------------------------// `stack merge`// ---------------------------------------------------------------------------
pub(super) const MERGE_NSID: &str = "sh.tangled.repo.merge";pub(super) const MERGE_CHECK_NSID: &str = "sh.tangled.repo.mergeCheck";
#[derive(clap::Args, Debug)]pub(crate) struct MergeArgs { /// Merge only positions 1..=N, counted from the bottom #[arg(long, value_name = "N")] pub through: Option<usize>, /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// Run the knot's merge check and stop; nothing is merged #[arg(long)] pub dry_run: bool, /// Print one JSON object describing what was written (or, with /// --dry-run, what would be) instead of the summary lines #[arg(long)] pub json: bool,}
/// What `stack merge` did, or would have done.////// A dry run reaches the knot's `mergeCheck` and stops, so `merged: false`/// there means "the knot says this applies cleanly" rather than "nothing/// happened" — a conflict is an error and never gets this far.#[derive(serde::Serialize, Debug, PartialEq)]pub(super) struct MergedJson { pub dry_run: bool, /// Whether the knot actually moved the branch. `false` on a dry run. pub merged: bool, pub repo_did: String, pub target_branch: String, /// The host that performed the merge — a git operation, not a record /// write, and the one part of this that no PDS could answer for. pub knot: String, /// The pulls that landed, or would, bottom first. Already-merged /// members contribute nothing and are not listed. pub pulls: Vec<String>, /// How many members the chain has in total, against which `pulls` is /// the subset `--through` and the members' states selected. pub total: usize, pub url: String,}
/// Everything one knot merge needs, however it was assembled. Shared with/// `pr merge`, whose plan is a stack of one.pub(in crate::cmd) struct MergePlan { pub knot: String, /// The account making the call: it signs the knot's service-auth token, /// and the merged status records land in *its* PDS. Not necessarily the /// repo's owner — see [`repo_facts`]. pub actor_did: String, /// Who the merge commit is authored by, which Tangled makes the pull's /// author rather than whoever pressed the button. Only read for a patch /// that is not a mailbox: `git am` carries its own authorship. pub author_did: String, /// How the repo's owner names it, when this account can find that out. /// `None` costs nothing on a current knot and is only a problem on one /// old enough to route by owner and name — see [`RepoOwner`]. pub owner: Option<RepoOwner>, pub repo_did: String, pub target_branch: String, /// Combined patch, bottom first — the text the knot applies as one /// merge, exactly as Tangled's own stacked merge sends it. pub patch: String, pub title: String, pub body: Option<String>, /// The pulls this merge lands, bottom first, to be marked merged. pub pulls: Vec<String>,}
/// A repo as its owner names it: the pair `sh.tangled.repo.merge` takes in/// `did` and `name`.////// Those two fields are the *legacy* spelling. A knot advertising the/// `repo-did-input` capability routes by `repo` — the repo's own DID — and/// ignores both, which is why a merge no longer needs them and why not/// having them is no longer a refusal.pub(in crate::cmd) struct RepoOwner { pub did: String, pub name: String,}
/// Where a repo lives, and who owns it if this account can tell.pub(in crate::cmd) struct RepoFacts { pub knot: String, pub owner: Option<RepoOwner>,}
/// The knot behind a repo DID, and the owner's name for it when that is/// discoverable from here.////// This used to be `own_repo_facts`, and reading it out of the acting/// account's own `sh.tangled.repo` records doubled as an ownership check:/// no record, no merge. That check was atgc's invention. A knot authorizes/// `sh.tangled.repo.merge` with `IsPushAllowed`, so every collaborator with/// push access may merge — as they can on tangled.org, which sends the merge/// under the *logged-in* account and not the owner's. Refusing here meant/// answering a question only the knot can answer, in the negative, without/// asking it.////// So the owner's record is now a source of two optional fields rather than/// a gate, and the fact that must be right — which host to call — falls back/// to the repo's own DID document, which names its knot to anyone.pub(in crate::cmd) async fn repo_facts(pds: &str, did: &str, repo_did: &str) -> Result<RepoFacts> { let (records, _truncated) = crate::clients::atproto::pds::list_records_all( pds, did, crate::lexicon::tangled::REPO_NSID, |page| { page.iter() .any(|r| r["value"]["repoDid"].as_str() == Some(repo_did)) }, ) .await?; for record in &records { if record["value"]["repoDid"].as_str() != Some(repo_did) { continue; } let knot = record["value"]["knot"].as_str().unwrap_or_default(); let name = record["value"]["name"] .as_str() .or_else(|| record["uri"].as_str().and_then(|u| u.rsplit('/').next())) .unwrap_or_default(); if knot.is_empty() || name.is_empty() { bail!("the repo record for {repo_did} names no knot or no name"); } return Ok(RepoFacts { knot: knot.to_string(), owner: Some(RepoOwner { did: did.to_string(), name: name.to_string(), }), }); }
crate::logging::debug::log(format!( "no sh.tangled.repo record for {repo_did} in {did}'s PDS; \ asking the repo's DID document which knot holds it" )); let Some(knot) = crate::clients::atproto::did::knot_from_did_doc(repo_did).await else { bail!( "cannot tell which knot holds {repo_did}: this account has no \ sh.tangled.repo record for it, and its DID document names no knot\n\ a repo created before knot v1.13 has no DID of its own and no document \ to name one; merge it from the owner's account or on tangled.org" ); }; Ok(RepoFacts { knot, owner: None })}
/// A record's latest round reduced to its patch text — public reads from/// the author's PDS, bounded the way `pr diff` bounds them. `label` names/// the pull in failures.pub(in crate::cmd) async fn latest_round_patch( pds: &str, did: &str, value: &serde_json::Value, label: &str,) -> Result<String> { // Where the bytes are is [`crate::model::pull::latest_patch`]'s to say; // fetching them is this function's. The reading used to be here, and it // knew only the `rounds[]` shape — so a pre-rounds record, which `pr // view` has always read, was refused by every `stack` path with "has no // rounds". let cid = match crate::model::pull::latest_patch(value, label)? { crate::model::pull::PatchBytes::Inline(patch) => return Ok(patch), crate::model::pull::PatchBytes::Blob { cid, size } => { if size > crate::cmd::pr::review::DEFAULT_MAX_BYTES { bail!( "{label}'s latest patch is {size} bytes compressed, over the {}-byte limit", crate::cmd::pr::review::DEFAULT_MAX_BYTES ); } cid } }; let bytes = crate::clients::atproto::pds::blob_bounded( pds, did, &cid, crate::cmd::pr::review::DEFAULT_MAX_BYTES, ) .await .map_err(|e| anyhow::anyhow!("could not read {label}'s latest patch: {e}"))?; crate::cmd::pr::review::decompress(&bytes, crate::cmd::pr::review::DEFAULT_MAX_BYTES)}
/// The verdict a `mergeCheck` response carries, read strictly rather than/// guessed at. This is the interesting half of `run_merge`'s knot call — the/// other half is the socket — and it is separated because the gate on the/// destructive call that follows must read a verdict that is actually there./// `unwrap_or(false)` here once turned a truncated body, an HTML error page/// from a proxy, or a renamed field into "clean", and then merged; the/// function's caller records that this happened for real. `Ok(None)` means/// clean; `Ok(Some(lines))` carries the conflict detail already formatted/// for the `bail!` that names the branch; `Err` means the knot did not/// answer with a verdict at all.fn interpret_merge_check(check: &serde_json::Value) -> Result<Option<String>> { let Some(conflicted) = check["is_conflicted"].as_bool() else { bail!( "the knot's merge check answered without an is_conflicted verdict\n\ refusing to merge on a non-answer (--debug shows the response body)" ); }; if !conflicted { return Ok(None); } let mut lines = String::new(); if let Some(conflicts) = check["conflicts"].as_array() { for c in conflicts { lines.push_str(&format!( " {} {}\n", c["filename"].as_str().unwrap_or("?"), c["reason"].as_str().unwrap_or(""), )); } } // A conflict with no files in it is how the knot spells some refusals — // an empty patch among them — so the message it did send is the only // lead worth printing. if lines.is_empty() { lines = format!( " (no file details from the knot{})\n", check["message"] .as_str() .or(check["error"].as_str()) .map(|m| format!(": {m}")) .unwrap_or_default() ); } Ok(Some(lines))}
/// Add `did` and `name` to a knot input when they are known.////// The pair a knot without the `repo-did-input` capability reads *in place/// of* `repo`, per the `sh.tangled.repo.merge` lexicon. A current knot/// ignores them, so they are sent when this account happens to hold the/// owner's record and left out otherwise, rather than being something a/// merge has to establish first.fn name_the_owner(input: &mut serde_json::Value, plan: &MergePlan) { let Some(owner) = &plan.owner else { return; }; input["did"] = serde_json::json!(owner.did); input["name"] = serde_json::json!(owner.name);}
/// Say what to do about a knot's refusal, for the one refusal that has an/// answer worth printing.////// `AccessControl` is the knot saying this account is not on the repo's push/// list. That is now reachable — atgc no longer decides for itself who may/// merge — and it is worth distinguishing from the other reason a merge/// stops, because the fix is somebody else's to make.fn explain_refusal(error: anyhow::Error, plan: &MergePlan) -> anyhow::Error { let Some(refusal) = error.downcast_ref::<crate::clients::tangled::knot::Refused>() else { return error; }; if refusal.tag != "AccessControl" { return error; } let owner = match &plan.owner { Some(owner) => format!("{}'s to give", owner.did), None => "the repo owner's to give".to_string(), }; crate::exit::fail( crate::exit::Exit::Denied, format!( "{error}\n\ {} authorizes a merge by push access, and this account has none on \ {}. Push access is {owner}, as a collaborator on the repo, which is \ not the same as the SSH key `atgc key add` registers", plan.knot, plan.repo_did, ), )}
/// Whether the knot composed this refusal itself, rather than stopping.////// The rule is [`crate::clients::tangled::knot::decided`]; this is only the/// unwrapping, and it has to run *before* [`explain_refusal`], which replaces/// the error with one carrying no `Refused` to find.fn knot_decided(error: &anyhow::Error) -> bool { error .downcast_ref::<crate::clients::tangled::knot::Refused>() .is_some_and(|r| crate::clients::tangled::knot::decided(&r.tag, r.status))}
/// Whose patch a merge is landing: the author of the chain's bottom member.////// Every member of a chain atgc can write is the same account's, so any/// member answers — and the bottom is the one whose commits sit first in the/// combined patch. Falls back to the acting account only when the chain/// carries no readable at-uri at all, which is a shape the walk that built it/// would already have refused.fn bottom_author(chain: &super::Chain<'_>) -> Option<String> { chain .members .first() .and_then(|m| m["uri"].as_str()) .and_then(crate::model::record::authority_of) .map(str::to_string)}
/// Say when a recorded mark is cutting nothing.////// The cut it was recorded to make is not the cut being made, and nothing/// else says so at the moment it matters: `stack mark` reports a stranded/// mark only when somebody asks it to, and the commands that act on the cut/// are the ones changing shape because of it.fn warn_stranded_marks(stranded: &[String]) { if stranded.is_empty() { return; } crate::term::say::warning!( Git, "{} recorded mark(s) sit outside this range and are cutting nothing: {}\n\ the members below are cut without them. `atgc stack rebase` carries marks and a \ plain `git rebase` does not; `atgc stack mark` shows where each one is, and \ `--forget <name>` drops one", stranded.len(), stranded.join(", "), );}
/// Say so when a failed merge call may have moved the branch anyway.////// **"Retry later" is the wrong advice for a call whose outcome is/// unknown**, and it is what [`crate::exit::Exit::Unreachable`] means. A/// knot that *refuses* — 401 from the push-access guard, 403 from the/// collaborator handler — has done nothing, and retrying after fixing the/// access is exactly right. A knot that dies mid-call, or times out, or/// answers 502, has either merged the patch or not, and nothing in the/// answer says which. Reporting the two the same way tells somebody to/// retry a merge that may already be sitting on the target branch.////// The distinction is the tag: a refusal atgc can name is one the knot/// composed on purpose, which means it got far enough to compose it. Anything/// else — no tag, a 5xx, a transport error that never became a `Refused` at/// all — leaves the question open, and the answer is to look before retrying.fn unknown_outcome(plan: &MergePlan) -> String { format!( "this may have merged anyway: {} did not answer, and a merge that reached it \ moves {} whether or not the answer came back. Check the branch before \ retrying — nothing here recorded these pulls as merged", plan.knot, plan.target_branch, )}
/// Check, merge, and record: one `mergeCheck`, one `merge`, then a merged/// status per pull. The status records are what the rest of the pull/// machinery reads — the knot changed the branch, and without them every/// listing would keep calling these pulls open.pub(in crate::cmd) async fn run_merge( agent: &jacquard::client::Agent<crate::clients::atproto::oauth::Session>, plan: &MergePlan, dry_run: bool,) -> Result<()> { use crate::lexicon::tangled::{PULL_STATUS_NSID, PullState}; use tangled_lexicon::sh_tangled::repo::pull::status::{Status as PullStatus, StatusStatus};
let mut check_input = serde_json::json!({ "branch": plan.target_branch, "patch": plan.patch, "repo": plan.repo_did, }); name_the_owner(&mut check_input, plan); let check = crate::clients::tangled::knot::xrpc(agent, &plan.knot, MERGE_CHECK_NSID, &check_input) .await?; if let Some(lines) = interpret_merge_check(&check)? { bail!( "the knot reports this merge would conflict with {}:\n{lines}\ rebase the branch, `stack resubmit`, and try again", plan.target_branch, ); } // The three lines below are this function's whole human output. Under // `--json` the caller prints one object covering all of it, so they are // suppressed here rather than duplicated there — and this is what the // `jsonout` global is for: `run_merge` is shared by two commands and has // neither's arguments. let quiet = crate::term::jsonout::active(); if !quiet { println!( "check: clean against {} on {}", plan.target_branch, plan.knot ); } if dry_run { if !quiet { println!("dry run; nothing merged"); } return Ok(()); }
let who = crate::clients::atproto::did::Identity::fetch(&plan.author_did).await; let author_name = who .handle .map(|h| format!("@{h}")) .unwrap_or_else(|| plan.author_did.clone()); let mut merge_input = serde_json::json!({ "branch": plan.target_branch, "patch": plan.patch, "repo": plan.repo_did, "commitMessage": plan.title, "authorName": author_name, "authorEmail": plan.author_did, }); name_the_owner(&mut merge_input, plan); if let Some(body) = &plan.body { merge_input["commitBody"] = serde_json::json!(body); } crate::clients::tangled::knot::xrpc(agent, &plan.knot, MERGE_NSID, &merge_input) .await // Decided *before* `explain_refusal`, which rebuilds the error into // an `exit::fail` and so is the last moment the knot's own `Refused` // is still downcastable. Reversing these two put the ambiguity // warning on a 401 that the knot had plainly decided. .map_err(|e| { let decided = knot_decided(&e); let e = explain_refusal(e, plan); match decided { true => e, false => e.context(unknown_outcome(plan)), } })?; if !quiet { println!( "merged {} pull(s) into {}", plan.pulls.len(), plan.target_branch ); }
// The knot's branch moved; now say so in records — one batch, one // commit, where a sequential loop could die partway and leave some // landed pulls reading open with no single message saying which. let mut ticker = Ticker::new(); let mut ops: Vec<Op> = Vec::new(); for uri in &plan.pulls { let record: PullStatus = PullStatus { pull: AtUri::new(uri.clone().into())?, status: StatusStatus::from_value(PullState::Merged.token().into()), created_at: Datetime::now(), extra_data: None, }; let rkey = ticker.next(None).to_string(); ops.push(Op::Create { nsid: PULL_STATUS_NSID, rkey: Key::any_owned(&rkey).map_err(|e| anyhow::anyhow!("bad TID {rkey}: {e}"))?, value: serde_json::to_value(&record)?, }); } if let Err(e) = crate::clients::atproto::record::batch(agent, "merged status", &plan.actor_did, ops, None) .await { bail!( "the knot merged the patch, but writing the merged statuses failed: {e}\n\ these are merged on the branch and still read open in listings:\n {}\n\ mark them in Tangled's web UI, or retry once the PDS is reachable", plan.pulls.join("\n ") ); } if !quiet { for uri in &plan.pulls { println!("status merged -> {uri}"); } } Ok(())}
/// Which of the bottom `through` members of a chain a merge actually/// combines, decided from state alone: a `"merged"` row already sits on the/// target branch and contributes nothing, `"open"` is what a merge combines,/// and anything else is refused rather than silently dropped or silently/// folded in. This is the choice that decides exactly which subset of a/// stack becomes the single most consequential write in this file, so it is/// separated from the per-member patch fetch that follows a selection —/// that half needs a live PDS read and stays where it is. `Ok` carries the/// indexes to include, in order; `Err` carries the index of the first row/// whose state refuses, leaving the caller to name that member in its own/// words.fn select_merge_range(states: &[&str], through: usize) -> Result<Vec<usize>, usize> { let mut selected = Vec::new(); for (index, state) in states.iter().enumerate().take(through) { match *state { "merged" => {} "open" => selected.push(index), _ => return Err(index), } } Ok(selected)}
/// Merge the current branch's stack — the whole of it, or `--through` a/// position counted from the bottom — as one combined patch, the way/// Tangled itself lands a stack.pub(in crate::cmd) async fn merge(args: MergeArgs) -> Result<()> { crate::term::jsonout::init(args.json); let selection = crate::config::account::select().await?; selection.announce(); let me = selection.did.clone();
let branch = git::current_branch()?; let remote_url = git::remote_url(&args.remote)?; let repo = resolve::repo_ref(&remote_url).await?;
// The shared preamble again, with merge's own words: this path ends at // the knot, where a wrong bottom is merged history. let rows = super::complete_rows(listing::Source::EVERY, &repo.did, "a merge").await?; let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect(); // `Whose::Anyones`: a merge writes no member record, only // its own status records, so the repo owner landing a contributor's // stack is exactly the case the help promises and `Whose::Mine` refuses. let chain = super::chain_for_branch( &items, &branch, &me, &super::closed_uris(&rows), listing::Source::EVERY, super::Whose::Anyones, "nothing to merge", "`atgc pr merge` lands a single pull", )?;
let total = chain.members.len(); let through = args.through.unwrap_or(total); if through == 0 || through > total { // A number the command line got wrong, which is `Usage` — the same // answer clap gives for a value outside a declared range, and not // the unclassified `1` that reads as "something went wrong, try // again". Nothing here will succeed on a retry. return Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "--through {through} is out of range; the stack has {total} pulls (1 = bottom)" ), )); }
let pds = crate::clients::atproto::did::pds_or_fail(&me).await?; let facts = repo_facts(&pds, &me, &repo.did).await?;
// Bottom through `through`: merged members contribute nothing (their // commits are already on the target), anything not cleanly open is // refused, and the rest are reduced to their latest round's patch. let states: Vec<String> = chain .members .iter() .map(|m| super::state_of(&rows, m["uri"].as_str().unwrap_or_default())) .collect(); let state_refs: Vec<&str> = states.iter().map(String::as_str).collect(); let selected = match select_merge_range(&state_refs, through) { Ok(selected) => selected, Err(index) => { let member = &chain.members[index]; let uri = member["uri"].as_str().unwrap_or_default(); let title = member["value"]["title"].as_str().unwrap_or("(untitled)"); let state = &states[index]; bail!("{title} ({uri}) is {state}; a stack merges bottom-up through open pulls only"); } }; // Every state here is "open": `selected` is exactly the members the // refusal above let through, and that is what it checks. let read = read_members( selected .iter() .map(|&index| (chain.members[index], "open".to_string())), ) .await?; let landing: Vec<String> = selected .iter() .map(|&index| { chain.members[index]["uri"] .as_str() .unwrap_or_default() .to_string() }) .collect(); let patches: Vec<String> = read.into_iter().map(|old| old.latest_patch).collect(); if landing.is_empty() { bail!("everything at or below position {through} is already merged; nothing to do"); }
let top = &chain.members[through - 1]; let target_branch = crate::model::pull::target_branch_of_row(top)?; let plan = MergePlan { knot: facts.knot, actor_did: me.clone(), // **The pulls' author, not whoever is merging.** The merge commit // belongs to the person whose patch it is, which is what Tangled's // own merge sends and what `pr merge` has always done. // // This was `me`, and was right only for as long as `own_chain` // refused anybody else's stack. Letting the repo owner merge a // contributor's stack made it wrong in a way no record shows and no // test here looked at: git attribution is permanent, and the // contributor's work would have landed under the maintainer's name. author_did: bottom_author(&chain).unwrap_or_else(|| me.clone()), owner: facts.owner, repo_did: repo.did.clone(), target_branch, patch: patches.join("\n"), title: top["value"]["title"] .as_str() .unwrap_or("(untitled)") .to_string(), body: top["value"]["body"].as_str().map(str::to_string), pulls: landing, };
if !args.json { println!( "merge: {} of {total} pull(s), bottom first, into {} as one commit series", plan.pulls.len(), plan.target_branch ); for uri in &plan.pulls { println!(" {uri}"); } } let agent = auth::agent_for_did(&me).await?; run_merge(&agent, &plan, args.dry_run).await?; // What to do next is advice about this checkout, not a fact about the // merge, so it is a note under --json and a line of the report otherwise. let next = format!( "next: git fetch {r} && git rebase {r}/{t}, then `atgc stack resubmit` \ reconciles the survivors above the merge", r = args.remote, t = plan.target_branch, ); if args.json { crate::term::jsonout::emit(&MergedJson { dry_run: args.dry_run, merged: !args.dry_run, repo_did: plan.repo_did.clone(), target_branch: plan.target_branch.clone(), knot: plan.knot.clone(), pulls: plan.pulls.clone(), total, url: format!("{}/pulls", repo.web_url), })?; if !args.dry_run { crate::term::say::note!(Git, "{next}"); } return Ok(()); } if !args.dry_run { crate::term::say::note!(Git, "{next}"); } Ok(())}
/// One member's record, fresh from the PDS at write time — the listing that/// planned the reconcile is not the copy to mutate.async fn fetch_member(pds: &str, did: &str, rkey: &str) -> Result<(Pull, Option<String>)> { let (value, cid) = crate::clients::atproto::pds::get_record(pds, did, PULL_NSID, rkey).await?; let pull: Pull = serde_json::from_value(value) .map_err(|e| anyhow::anyhow!("pull {rkey} does not parse as a stacked pull: {e}"))?; Ok((pull, cid))}
#[cfg(test)]mod tests { use super::{ OldMember, Plan, Planned, Slot, change_id_header, interpret_merge_check, reconcile, select_merge_range, two_column_listing, with_change_id_header, };
/// Pinned so a refactor of the four `bail!` sites this feeds can't /// silently reflow the listing: two spaces, the two columns, two /// spaces between them, one row per line, in the order given. #[test] fn listing_renders_two_indented_columns_in_order() { let rendered = two_column_listing([("abc1234", "first commit"), ("def5678", "second commit")]); assert_eq!( rendered, " abc1234 first commit\n def5678 second commit\n" ); }
/// No rows is the empty string, not a stray newline — every caller /// checks emptiness itself before building the listing, but the helper /// should not need that guard to behave. #[test] fn listing_of_nothing_is_empty() { let rendered = two_column_listing(std::iter::empty::<(&str, &str)>()); assert_eq!(rendered, ""); }
/// The five fates a reconcile can hand a member, as one word each and /// with the identifiers each fate actually has: an `add` has no record /// key yet, and nothing but an `update` or an `add` names a commit. /// `rounds` is what the record will hold afterwards, which is the field /// a caller would otherwise have to derive from the action. #[test] fn every_reconcile_fate_reports_its_own_word_and_identifiers() { let old = vec![ member("3aaa", "Ione", "patch one", "open", None), member("3bbb", "Itwo", "patch two", "merged", None), ]; let planned = vec![Planned { shas: vec!["abc1234def".to_string()], change_ids: vec!["Ione".to_string()], subject: "the commit".to_string(), body: None, patch: "patch one changed".to_string(), images: Ok(crate::cmd::images::Images::none()), }];
let update = super::reconciled_member_json( 1, &Slot::Update { index: 0, member: 0, }, &old, &planned, ); assert_eq!(update.action, "update"); assert_eq!(update.rkey.as_deref(), Some("3aaa")); assert_eq!(update.sha.as_deref(), Some("abc1234def")); assert_eq!(update.rounds, 2, "a round is appended");
let relink = super::reconciled_member_json( 1, &Slot::Keep { member: 0, relink: true, }, &old, &planned, ); assert_eq!(relink.action, "relink"); assert_eq!(relink.sha, None, "no commit touches a relinked member"); assert_eq!(relink.rounds, 1);
let keep = super::reconciled_member_json( 1, &Slot::Keep { member: 0, relink: false, }, &old, &planned, ); assert_eq!(keep.action, "keep");
let frozen = super::reconciled_member_json(2, &Slot::Frozen { member: 1 }, &old, &planned); assert_eq!(frozen.action, "merged"); assert_eq!(frozen.rkey.as_deref(), Some("3bbb"));
let add = super::reconciled_member_json(3, &Slot::Add { index: 0 }, &old, &planned); assert_eq!(add.action, "add"); assert_eq!(add.rkey, None, "the PDS mints the key for a new record"); assert_eq!(add.title, "the commit"); assert_eq!(add.rounds, 1); }
fn member(rkey: &str, id: &str, patch: &str, state: &str, dep: Option<&str>) -> OldMember { OldMember { uri: format!("at://did:plc:me/sh.tangled.repo.pull/{rkey}"), rkey: rkey.to_string(), title: format!("pull {rkey}"), state: state.to_string(), change_ids: id .split(' ') .filter(|s| !s.is_empty()) .map(str::to_string) .collect(), latest_patch: patch.to_string(), dependent_on: dep.map(|d| format!("at://did:plc:me/sh.tangled.repo.pull/{d}")), body: None, rounds: 1, } }
/// The whole point of predicting CIDs: the patch takes its final, /// comparable text at planning time — message rewritten, diff bytes /// sacred. #[test] fn prediction_finalizes_the_message_and_never_touches_the_diff() { let _repo = crate::testutil::TempRepo::new("predict-patch"); std::fs::write("logo.svg", "<svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap(); let body = "With proof: "; let images = Ok(crate::cmd::images::scan(body).expect("scans")); let patch = "From abc Mon Sep 17 00:00:00 2001\n\ Subject: [PATCH] feat: x\n\n\ With proof: \n\ ---\n \ logo.svg | 1 +\n\ diff --git a/logo.svg b/logo.svg\n\ + the diff may mention  and must keep it\n"; let (new_body, new_patch) = super::predict_member_rewrites( "did:plc:me", Some(body.to_string()), patch.to_string(), &images, ) .expect("rewrites"); assert!(new_body.unwrap().contains("blob+at://did:plc:me/bafkrei")); let scissors = new_patch.find("\n---\n").expect("still a patch"); assert!( new_patch[..scissors].contains("blob+at://did:plc:me/bafkrei"), "message carries the prediction" ); assert!( new_patch[scissors..].contains(""), "diff bytes are untouched" ); }
/// A planned member. `ids` is space-separated for a member of several /// commits — `commit("Ia Ib", …)` is the two-commit member the ordinary /// `commit("Ia", …)` is the one-commit case of. fn commit(ids: &str, patch: &str) -> Planned { let change_ids: Vec<String> = ids.split(' ').map(str::to_string).collect(); Planned { shas: change_ids.iter().map(|id| format!("{id:0<40}")).collect(), change_ids, subject: format!("commit {ids}"), body: None, patch: patch.to_string(), images: Ok(crate::cmd::images::Images::none()), } }
fn plan(old: &[OldMember], new: &[Planned], prune: bool) -> Plan { reconcile(old, new, prune).expect("plans") }
/// Marks cut the range: a mark *ends* a member, and the top member runs /// to HEAD however far that is. An unmarked range is one member per /// commit, which is every stack written before marks existed. #[test] fn marks_end_the_members() { let marks = [(1usize, "part1".to_string())]; let cut = super::cut_at_marks(4, &marks); assert_eq!(cut.groups, vec![vec![0, 1], vec![2, 3]]); assert_eq!(cut.labels, vec![Some("part1".to_string()), None]);
// Two marks, three members, and a mark on the last commit does not // mint an empty member above it. let marks = [(0usize, "one".to_string()), (3usize, "three".to_string())]; let cut = super::cut_at_marks(4, &marks); assert_eq!(cut.groups, vec![vec![0], vec![1, 2, 3]]);
let bare = super::cut_at_marks(4, &[]); assert_eq!(bare.groups, super::one_group_per_commit(4)); assert!(bare.labels.iter().all(Option::is_none)); }
/// A member of several commits gets a header per commit, and a member of /// one comes out exactly as it always did — which is what keeps an old /// stack's bytes comparing equal on its next reconcile. #[test] fn every_message_of_a_members_mailbox_carries_its_own_change_id() { let one = "From aaa Mon Sep 17 00:00:00 2001\nSubject: [PATCH 1/2] a\n\nbody a\n"; let two = "From bbb Mon Sep 17 00:00:00 2001\nSubject: [PATCH 2/2] b\n\nbody b\n"; let mailbox = format!("{one}{two}"); assert_eq!( crate::clients::git::patch::message_offsets(&mailbox), vec![0, one.len()] );
let ids = ["Ia".to_string(), "Ib".to_string()]; let out = super::with_change_id_headers(&mailbox, &ids).expect("injects"); assert_eq!(out.matches("Change-Id: ").count(), 2, "{out}"); assert_eq!(super::change_id_headers(&out), ids, "{out}");
// One commit, one header, byte for byte what the single-commit path // produced before members could hold more. let solo = super::with_change_id_headers(one, &ids[..1]).expect("injects"); assert_eq!(solo, with_change_id_header(one, "Ia").expect("injects"));
// A count that does not match the mailbox is refused rather than // guessed at: a header on the wrong commit is a member that answers // to somebody else's change-id. let err = super::with_change_id_headers(&mailbox, &ids[..1]) .unwrap_err() .to_string(); assert!(err.contains("2 message(s) but 1 commit(s)"), "{err}"); }
/// The grouping lives in the records: a member owning two change-ids /// claims the run its commits span, and an unclaimed commit inside that /// span joins it rather than splintering the member. #[test] fn the_existing_records_say_where_the_cuts_are() { let old = [ member("a", "Ia Ib", "pa", "open", None), member("c", "Ic", "pc", "open", Some("a")), ]; // Branch order: Ia, (a new commit), Ib, Ic. The new one is inside // member a's span, so it joins it. let commits = ["Ia", "Inew", "Ib", "Ic"].map(String::from); let ids = |sha: &str| Some(sha.to_string()); // groups_from_members reads change-ids off real commits, so the // shape is asserted through the pure part: spans over these ids. let groups = super::groups_from_ids(&commits.iter().map(|c| ids(c)).collect::<Vec<_>>(), &old) .expect("groups"); assert_eq!(groups, vec![vec![0, 1, 2], vec![3]]); }
/// Two members whose commits are interleaved cannot both be a run, and /// that is said rather than silently re-cut. #[test] fn interleaved_members_are_refused() { let old = [ member("a", "Ia Ib", "pa", "open", None), member("c", "Ic", "pc", "open", Some("a")), ]; let ids: Vec<Option<String>> = ["Ia", "Ic", "Ib"] .iter() .map(|s| Some(s.to_string())) .collect(); let err = super::groups_from_ids(&ids, &old).unwrap_err().to_string(); assert!(err.contains("interleaved"), "{err}"); }
/// The no-op that makes a rerun safe: same ids, same bytes, same order /// — nothing to send, and in particular no round appended per pull the /// way a naive port of Tangled's resubmit would. #[test] fn an_unchanged_stack_reconciles_to_nothing() { let old = [ member("a", "Ia", "pa", "open", None), member("b", "Ib", "pb", "open", Some("a")), ]; let new = [commit("Ia", "pa"), commit("Ib", "pb")]; let p = plan(&old, &new, false); assert_eq!( p.chain, [ Slot::Keep { member: 0, relink: false }, Slot::Keep { member: 1, relink: false }, ] ); assert!(p.drops.is_empty()); assert!(p.anchor.is_none()); }
/// An amend mid-stack: the amended commit and everything above it carry /// new bytes and get rounds; the untouched bottom does not. #[test] fn an_amend_appends_rounds_only_where_bytes_changed() { let old = [ member("a", "Ia", "pa", "open", None), member("b", "Ib", "pb", "open", Some("a")), member("c", "Ic", "pc", "open", Some("b")), ]; let new = [ commit("Ia", "pa"), commit("Ib", "pb-amended"), commit("Ic", "pc-rebased"), ]; let p = plan(&old, &new, false); assert_eq!( p.chain, [ Slot::Keep { member: 0, relink: false }, Slot::Update { index: 1, member: 1 }, Slot::Update { index: 2, member: 2 }, ] ); }
/// A reorder with identical bytes is a relink: records update, no /// rounds. (In practice reordered commits change bytes too — the sha in /// the From line moves — and become Updates; this pins the pure case.) #[test] fn a_reorder_relinks_without_rounds() { let old = [ member("a", "Ia", "pa", "open", None), member("b", "Ib", "pb", "open", Some("a")), ]; let new = [commit("Ib", "pb"), commit("Ia", "pa")]; let p = plan(&old, &new, false); assert_eq!( p.chain, [ Slot::Keep { member: 1, relink: true }, Slot::Keep { member: 0, relink: true }, ] ); }
/// A new commit mid-stack: an Add, and the member above it relinks to /// the minted record even though its own bytes did not move. #[test] fn an_inserted_commit_adds_and_relinks_its_successor() { let old = [ member("a", "Ia", "pa", "open", None), member("b", "Ib", "pb", "open", Some("a")), ]; let new = [ commit("Ia", "pa"), commit("Inew", "pnew"), commit("Ib", "pb"), ]; let p = plan(&old, &new, false); assert_eq!( p.chain, [ Slot::Keep { member: 0, relink: false }, Slot::Add { index: 1 }, Slot::Keep { member: 1, relink: true }, ] ); }
/// The bottom merged and the branch rebased past it: the merged member /// is the anchor, never a drop, and the surviving bottom keeps pointing /// at it without so much as a relink. #[test] fn a_merged_bottom_becomes_the_anchor() { let old = [ member("a", "Ia", "pa", "merged", None), member("b", "Ib", "pb", "open", Some("a")), ]; let new = [commit("Ib", "pb")]; let p = plan(&old, &new, false); assert_eq!(p.anchor, Some(0)); assert!(p.drops.is_empty()); assert_eq!( p.chain, [Slot::Keep { member: 1, relink: false }] ); }
/// A live pull whose commit vanished needs --prune; without it the /// whole reconcile refuses, because relinking around it would leave a /// fork the appview rejects. #[test] fn a_dropped_commit_needs_prune() { let old = [ member("a", "Ia", "pa", "open", None), member("b", "Ib", "pb", "open", Some("a")), ]; let new = [commit("Ib", "pb")]; let err = reconcile(&old, &new, false).unwrap_err().to_string(); assert!(err.contains("--prune"), "{err}"); assert!(err.contains("pull a"), "{err}");
let p = plan(&old, &new, true); assert_eq!(p.drops, [0]); assert_eq!( p.chain, [Slot::Keep { member: 1, relink: true }] ); }
/// A *closed* pull whose commit vanished needs no flag and loses no /// record: closing it already said it was out of the stack, so the /// reconcile routes the chain past it and leaves it alone. Deleting it /// would take the comment explaining the close with it, which is the one /// outcome someone who closed a pull deliberately cannot want. #[test] fn a_closed_member_is_retired_not_deleted() { let old = [ member("a", "Ia", "pa", "closed", None), member("b", "Ib", "pb", "open", Some("a")), ]; let new = [commit("Ib", "pb")]; let p = plan(&old, &new, false); assert_eq!(p.retired, [0]); assert!(p.drops.is_empty()); assert_eq!( p.anchor, None, "a closed pull never landed, so it anchors nothing" ); assert_eq!( p.chain, [Slot::Keep { member: 1, relink: true }] ); }
/// The shape that forced a fresh PR before this: a closed member in the /// *middle* of a chain. The member above it relinks down to the one /// below, so the stack closes up with one write and no deletion. #[test] fn a_closed_member_in_the_middle_relinks_the_chain_around_it() { let old = [ member("a", "Ia", "pa", "open", None), member("b", "Ib", "pb", "closed", Some("a")), member("c", "Ic", "pc", "open", Some("b")), ]; let new = [commit("Ia", "pa"), commit("Ic", "pc")]; let p = plan(&old, &new, false); assert_eq!(p.retired, [1]); assert!(p.drops.is_empty()); assert_eq!( p.chain, [ Slot::Keep { member: 0, relink: false }, Slot::Keep { member: 2, relink: true }, ] ); }
/// Unknown state blocks every destructive path: a vanished member that /// might be merged, and an update to a member that might be merged. #[test] fn unknown_state_refuses_destructive_fates() { let old = [member("a", "Ia", "pa", "?", None)]; let err = reconcile(&old, &[commit("Ix", "px")], true) .unwrap_err() .to_string(); assert!(err.contains("cannot be settled"), "{err}");
let err = reconcile(&old, &[commit("Ia", "pa-changed")], false) .unwrap_err() .to_string(); assert!(err.contains("cannot be settled"), "{err}");
// Identical bytes and unmoved order touch nothing, so unknown state // is tolerable there. let p = plan(&old, &[commit("Ia", "pa")], false); assert_eq!( p.chain, [Slot::Keep { member: 0, relink: false }] ); }
/// A merged member matched by an unchanged commit holds its place, /// frozen; matched by a changed one, the reconcile refuses — a merged /// pull is never updated. #[test] fn a_merged_member_is_frozen_or_the_reconcile_refuses() { let old = [ member("a", "Ia", "pa", "merged", None), member("b", "Ib", "pb", "open", Some("a")), ]; let new = [commit("Ia", "pa"), commit("Ib", "pb")]; let p = plan(&old, &new, false); assert_eq!( p.chain, [ Slot::Frozen { member: 0 }, Slot::Keep { member: 1, relink: false } ] );
let err = reconcile( &old, &[commit("Ia", "pa-edited"), commit("Ib", "pb")], false, ) .unwrap_err() .to_string(); assert!(err.contains("merged"), "{err}"); }
/// The no-op guarantee must survive the formatter: `git format-patch` /// signs its output with the git version, so raw byte equality read /// "changed" after a git upgrade, from another machine, or against a /// round the web appended — and a "no-op" rerun appended a round per /// pull. #[test] fn the_signature_block_does_not_count_as_change() { use super::comparable_patch; let v1 = "From x\nSubject: [PATCH] a\n\ndiff --git a/f b/f\n+x\n-- \n2.43.0\n"; let v2 = "From x\nSubject: [PATCH] a\n\ndiff --git a/f b/f\n+x\n-- \n2.53.0\n"; assert_eq!(comparable_patch(v1), comparable_patch(v2));
let old = [ member("a", "Ia", v1, "open", None), member("b", "Ib", "pb", "open", Some("a")), ]; let new = [commit("Ia", v2), commit("Ib", "pb")]; let p = plan(&old, &new, false); assert_eq!( p.chain, [ Slot::Keep { member: 0, relink: false }, Slot::Keep { member: 1, relink: false }, ], "a formatter delta is not a new round" );
// A real content delta still counts. let changed = "From x\nSubject: [PATCH] a\n\ndiff --git a/f b/f\n+y\n-- \n2.43.0\n"; let p = plan(&old, &[commit("Ia", changed), commit("Ib", "pb")], false); assert_eq!( p.chain[0], Slot::Update { index: 0, member: 0 } ); }
/// The empty-commit refusal: the knot refuses to merge an empty patch /// (its merge check answers "conflicted" with no files), so a stack /// carrying one is refused at the door. `patch_has_diff` is what tells /// an empty commit's patch — headers, no diff — from a real one. #[test] fn empty_patches_are_refused_by_name() { use super::{patch_has_diff, refuse_empty_patches}; let full = commit( "Ia", "From x\nSubject: [PATCH] a\n\ndiff --git a/f b/f\n+x\n", ); let mut empty = commit("Ib", "From y\nSubject: [PATCH] b\n\n---\n2.53.0\n"); empty.shas = vec!["beefbeefbeef".into()]; empty.subject = "Empty: docs to follow".into(); assert!(patch_has_diff(&full.patch)); assert!(!patch_has_diff(&empty.patch));
assert!(refuse_empty_patches(std::slice::from_ref(&full)).is_ok()); let err = refuse_empty_patches(&[full, empty]) .unwrap_err() .to_string(); assert!(err.contains("beefbee"), "{err}"); assert!(err.contains("Empty: docs to follow"), "{err}"); assert!(err.contains("empty patch"), "{err}"); }
/// One identity per record and per commit, or matching means nothing — /// and a cherry-pick keeps the trailer, so the duplicate-commit case is /// an ordinary user accident, not sabotage. #[test] fn duplicate_change_ids_are_refused_on_both_sides() { let old = [ member("a", "Idup", "pa", "open", None), member("b", "Idup", "pb", "open", Some("a")), ]; let err = reconcile(&old, &[commit("Idup", "pa")], false) .unwrap_err() .to_string(); assert!(err.contains("both answer to change-id Idup"), "{err}");
let old = [member("a", "Ia", "pa", "open", None)]; let err = reconcile(&old, &[commit("Ia", "pa"), commit("Ia", "px")], false) .unwrap_err() .to_string(); assert!(err.contains("carry the change-id Ia"), "{err}"); }
/// A member whose latest round has no Change-Id header cannot be /// matched to anything and the reconcile says whose fault that is. #[test] fn a_member_with_no_change_id_is_refused_by_name() { let old = [member("a", "", "pa", "open", None)]; let err = reconcile(&old, &[commit("Ia", "pa")], false) .unwrap_err() .to_string(); assert!(err.contains("pull a"), "{err}"); assert!(err.contains("no Change-Id header"), "{err}"); }
/// The pull body is the commit body minus the Change-Id trailer, found /// live: the first stacked pull whose commit had no body beyond its /// added trailer went up with `Change-Id: I…` as its whole description. #[test] fn pull_bodies_do_not_carry_the_change_id_trailer() { use super::strip_change_id_trailers as strip; // A trailer-only body is no body. assert_eq!(strip("Change-Id: Iabc\n"), ""); // Prose stays; the trailer paragraph goes. assert_eq!(strip("Real prose.\n\nChange-Id: Iabc\n"), "Real prose."); // Other trailers in the block survive; only Change-Id leaves. assert_eq!( strip("Prose.\n\nSigned-off-by: A <a@b.c>\nChange-Id: Iabc\n"), "Prose.\n\nSigned-off-by: A <a@b.c>" ); // A Change-Id quoted mid-body is prose, not bookkeeping. assert_eq!( strip("See Change-Id: Iabc for context.\n\nMore prose.\n"), "See Change-Id: Iabc for context.\n\nMore prose." ); // No trailer at all: untouched. assert_eq!(strip("Just a body.\n"), "Just a body."); // CRLF bodies split on their own paragraph breaks; before the // normalization the whole body read as one trailer block, and this // first paragraph — prose that happens to open with the words // Change-Id — was deleted along with the real trailer. assert_eq!( strip("Change-Id: Iref, quoted in prose.\r\n\r\nChange-Id: Iabc\r\n"), "Change-Id: Iref, quoted in prose." ); }
/// The header parse the whole reconcile keys on. #[test] fn reads_the_change_id_header_and_only_the_header() { let patch = "From abc Mon Sep 17 00:00:00 2001\nSubject: [PATCH] x\n\ Change-Id: Ifromheader\n\n\ body mentions Change-Id: Ifrombody\n---\n"; assert_eq!(change_id_header(patch).as_deref(), Some("Ifromheader")); assert_eq!(change_id_header("Subject: x\n\nbody\n"), None); }
const PATCH: &str = "From abc123 Mon Sep 17 00:00:00 2001\n\ From: A U Thor <a@example.invalid>\n\ Date: Sat, 9 Aug 2026 12:00:00 +0000\n\ Subject: [PATCH] Add b\n\ \n\ ---\n b.txt | 1 +\n";
/// The header goes into the mail header block — before the first blank /// line — because that is the only place the appview's parser reads it. #[test] fn injects_the_header_into_the_header_block() { let out = with_change_id_header(PATCH, "Iabcdef").unwrap(); let headers = out.split("\n\n").next().unwrap(); assert!(headers.contains("\nChange-Id: Iabcdef"), "{out}"); assert!(headers.contains("Subject: [PATCH] Add b"), "{out}"); // The body is byte-identical. assert_eq!(out.split("\n\n").nth(1), PATCH.split("\n\n").nth(1)); }
/// Idempotent: a patch that already carries the header keeps exactly /// one, whatever id a re-run would have used. #[test] fn does_not_double_an_existing_header() { let once = with_change_id_header(PATCH, "Iabcdef").unwrap(); let twice = with_change_id_header(&once, "Iother").unwrap(); assert_eq!(once, twice); assert_eq!(twice.matches("Change-Id:").count(), 1); }
/// A body mentioning "Change-Id:" — a commit message quoting one, say — /// must not suppress the real header. #[test] fn a_change_id_in_the_body_is_not_a_header() { let patch = "From abc Mon Sep 17 00:00:00 2001\nSubject: [PATCH] x\n\n\ quoting Change-Id: Iquoted here\n---\n"; let out = with_change_id_header(patch, "Ireal").unwrap(); let headers = out.split("\n\n").next().unwrap(); assert!(headers.contains("Change-Id: Ireal"), "{out}"); }
/// Garbage in, error out — not a patch with a header spliced somewhere. #[test] fn refuses_a_patch_with_no_header_block() { let err = with_change_id_header("not a patch", "I1").unwrap_err(); assert!(err.to_string().contains("malformed"), "{err}"); }
/// No verdict at all — a truncated body, or an HTML error page from a /// proxy that never got near the knot — must refuse rather than default /// to clean. This is the exact failure mode `unwrap_or(false)` hid once. #[test] fn a_merge_check_with_no_verdict_refuses_rather_than_defaulting_clean() { let err = interpret_merge_check(&serde_json::json!({})).unwrap_err(); assert!(err.to_string().contains("is_conflicted"), "{err}"); }
/// The clean answer: no conflict lines, nothing to bail on. #[test] fn a_clean_merge_check_carries_no_conflict_lines() { let verdict = interpret_merge_check(&serde_json::json!({"is_conflicted": false})).unwrap(); assert_eq!(verdict, None); }
/// A conflicted verdict with files: each conflict becomes its own /// indented line, filename then reason. #[test] fn a_conflicted_merge_check_lists_each_file_and_reason() { let verdict = interpret_merge_check(&serde_json::json!({ "is_conflicted": true, "conflicts": [ {"filename": "a.rs", "reason": "both modified"}, {"filename": "b.rs", "reason": "deleted in HEAD"}, ], })) .unwrap() .expect("conflicted"); assert_eq!(verdict, " a.rs both modified\n b.rs deleted in HEAD\n"); }
/// A conflicted verdict with no file details — how the knot spells some /// refusals, an empty patch among them — still gives the caller a line /// to print rather than nothing at all. #[test] fn a_conflicted_merge_check_with_no_files_falls_back_to_a_placeholder_line() { let verdict = interpret_merge_check(&serde_json::json!({"is_conflicted": true})) .unwrap() .expect("conflicted"); assert_eq!(verdict, " (no file details from the knot)\n"); }
/// The one place a wrong guess is unrecoverable: a record naming a real /// branch reads back exactly that branch. #[test] fn target_branch_of_reads_the_named_branch() { let member = serde_json::json!({"value": {"target": {"branch": "release/1.0"}}}); assert_eq!( crate::model::pull::target_branch_of_row(&member).unwrap(), "release/1.0" ); }
/// An empty string is not a branch name; refuse rather than land on "". #[test] fn target_branch_of_refuses_an_empty_branch_field() { let member = serde_json::json!({"uri": "at://did:plc:me/sh.tangled.repo.pull/abc", "value": {"target": {"branch": ""}}}); let err = crate::model::pull::target_branch_of_row(&member).unwrap_err(); assert_eq!( err.to_string(), "at://did:plc:me/sh.tangled.repo.pull/abc names no target branch; \ refusing to guess where this stack lands" ); }
/// No `target` at all — the old default-to-"main" path this comment /// warns about — refuses by the same message, naming the member when it /// can and falling back to a generic label when it cannot. #[test] fn target_branch_of_refuses_a_missing_branch_field() { let member = serde_json::json!({}); let err = crate::model::pull::target_branch_of_row(&member).unwrap_err(); assert_eq!( err.to_string(), "a stack member names no target branch; refusing to guess where this stack lands" ); }
/// The ordinary case: some merged rows skipped, the rest — all open — /// selected in order. #[test] fn select_merge_range_skips_merged_and_keeps_open_in_order() { let states = ["merged", "open", "open"]; assert_eq!(select_merge_range(&states, 3), Ok(vec![1, 2])); }
/// `through` bounds how far up the walk goes; rows above it are never /// looked at, merged or not. #[test] fn select_merge_range_stops_at_through() { let states = ["open", "open", "closed"]; assert_eq!(select_merge_range(&states, 2), Ok(vec![0, 1])); }
/// All merged, nothing above `through` to disqualify it: an empty /// selection, not an error — the caller decides that an empty selection /// means nothing to do. #[test] fn select_merge_range_of_an_all_merged_prefix_is_empty_not_an_error() { let states = ["merged", "merged"]; assert_eq!(select_merge_range(&states, 2), Ok(vec![])); }
/// Anything that is not "merged" or "open" — closed, unknown, a stale /// index reading "?" — refuses at the first offending index rather than /// silently skipping or silently including it. #[test] fn select_merge_range_refuses_at_the_first_non_open_non_merged_row() { let states = ["merged", "open", "closed", "open"]; assert_eq!(select_merge_range(&states, 4), Err(2)); }
/// The refusal only looks within `through`; a bad row past the cutoff /// does not stop a merge that never reaches it. #[test] fn select_merge_range_does_not_see_a_bad_row_past_through() { let states = ["open", "merged", "closed"]; assert_eq!(select_merge_range(&states, 2), Ok(vec![0])); }
/// A patch fixture in the shape `format_patch_one` emits, with the /// `Change-Id` header `with_change_id_header` injects. fn patch_with_message(message: &str) -> String { format!( "From cff78b1 Mon Sep 17 00:00:00 2001\n\ From: A U Thor <a@b.c>\n\ Date: Wed, 19 Aug 2026 14:25:41 -0400\n\ Subject: [PATCH] feat: a thing\n\ Change-Id: Ithing\n\ \n\ {message}---\n \ f | 1 +\n" ) }
/// The body a patch generates is `%b` with the trailer off — the same /// text `subject_and_body` hands `stack create`, which is the whole /// point: the two have to agree or every stored body reads as edited. #[test] fn a_patch_yields_the_body_its_commit_message_would() { assert_eq!( super::body_of_patch(&patch_with_message( "Why this change.\n\nAnd a second paragraph.\n\nChange-Id: Ithing\n" )), Some("Why this change.\n\nAnd a second paragraph.".to_string()) ); // A commit whose message is a subject and a trailer has no body, // exactly as `strip_change_id_trailers` decides for the commit. assert_eq!( super::body_of_patch(&patch_with_message("Change-Id: Ithing\n")), None ); // No message at all: the header block runs straight into the // scissors. assert_eq!(super::body_of_patch(&patch_with_message("")), None); // Not a patch: no verdict, which the caller reads as "not // generated from this" and so keeps what is stored. assert_eq!(super::body_of_patch("nothing mail-shaped here"), None); }
/// The comparison a round turns on: a description nobody has written /// over is regenerated from the commit, and one somebody has is not. #[test] fn only_an_untouched_body_still_belongs_to_its_commit() { let patch = patch_with_message("Why this change.\n\nChange-Id: Ithing\n"); let mut m = member("aaa", "Ithing", &patch, "open", None);
m.body = Some("Why this change.".to_string()); assert!(m.body_is_its_commits(), "the body the last round generated");
m.body = Some("Why this change.\n\n".to_string()); assert!(!m.body_is_its_commits(), "a body with a screenshot added");
m.body = None; assert!(!m.body_is_its_commits(), "a body someone cleared");
// A commit with no message body and a record with no body agree, // so an amended message still reaches a pull that never had one. let bare = patch_with_message("Change-Id: Ithing\n"); let mut m = member("bbb", "Ithing", &bare, "open", None); assert!(m.body_is_its_commits(), "neither side has a body"); // `pr edit --body ''` leaves the field present and empty; that is // the same nothing, not an edit to preserve. m.body = Some(" ".to_string()); assert!(m.body_is_its_commits(), "an empty body is no body"); }}