1,1 GB helyett 59 MB, a teljes historyval együtt: ez a blobless clone

A CI-ban vagy egyetlen commitot klónozol, vagy a fetch-depth: 0-val mindent. A partial clone a kimaradt középút: a react 1,1 GB-os klónja helyett 59 MB, mind a 21 658 commitjával. Megmértem, hol gyors és hol fáj nagyon.

A legtöbb CI-konfigban két Git-klónozási beállítás létezik. Az egyik a default, amihez senki nem nyúl: az actions/checkout alapból fetch-depth: 1-gyel dolgozik, vagyis egyetlen commitot tölt le. A másik a fetch-depth: 0, amit akkor ír be az ember, amikor a semantic-release, a changelog-generátor vagy a SonarQube sírni kezd, hogy nincs history. Az első 13 MB, a második 1,1 GB – legalábbis a react repón, ma délelőtt mérve. Arról viszont ritkán esik szó, hogy a kettő között van egy harmadik mód, ami 59 MB, és mégis ott van benne mind a 21 658 commit.

Diagram: három Git-klóntípus objektumgráfja – teljes klón minden objektummal, shallow clone elvágott commit-lánccal, blobless clone teljes commit- és tree-készlettel, szaggatottan jelölt hiányzó blobokkal

Commit, tree, blob – és hogy mit töltesz le valójában

A Git objektummodellje három építőkockából áll: a commit egy pillanatfelvétel metaadatokkal, a tree egy könyvtár tartalomjegyzéke, a blob pedig maga a fájltartalom. Egy sima git clone mindhármat letölti a teljes történelemre – minden fájl minden valaha létezett verzióját. Egy hosszú életű repóban ez a historikus adat teszi ki a méret nagy részét, és a napi munkához az égvilágon semmi szükség rá. Erre a felismerésre épül a partial clone: a kliens megmondhatja a szervernek, hogy bizonyos objektumtípusokat ne küldjön előre, majd ha mégis kell valamelyik, kéri lustán, menet közben.

A gyakorlatban két szűrő érdekes. A --filter=blob:none (blobless clone) minden commitot és tree-t letölt, de blobot csak a checkouthoz szükségeseket. A --filter=tree:0 (treeless clone) még durvább: csak a commitokat hozza, tree-t és blobot is on demand kér. Mindkettőt támogatja a GitHub és a GitLab is – és itt egy apróság, amit érdemes tudni: a filter csak kérés. A szerver jogosan meg is tagadhatja, és ilyenkor szó nélkül teljes klónt kapsz. Hiba nincs, csak a pipeline lesz lassabb, mint várnád.

Négy klón, egy repo

Lemértem mind a négy változatot a facebook/react repón (Git 2.47, GitHub-ról, ugyanarról a gépről, egymás után):

git clone <url>                      49,2 s   1,1 GB   21 658 commit
git clone --depth=1 <url>             2,8 s    13 MB        1 commit
git clone --filter=blob:none <url>    6,1 s    59 MB   21 658 commit
git clone --filter=tree:0 <url>       5,0 s    30 MB   21 658 commit

A blobless clone a teljes klón méretének nagyjából 5%-a, miközben a commit-history hiánytalanul megvan. A git log, a git merge-base, a git describe --tags pontosan úgy működik, mint egy teljes klónban – az utóbbi nálam azonnal visszaadta, hogy a HEAD 192 committal jár az utolsó tag előtt. Sőt, a git log --oneline -- <fájl> is 8 ezredmásodperc alatt futott le, hálózat nélkül: a fájltörténet kiszámításához a Gitnek csak az objektumazonosítók kellenek a tree-kből, a fájlok tartalma nem.

Amiről a shallow clone hallgat

Ezzel szemben a --depth=1 nem szűr, hanem csonkol: elvágja a commit-láncot, és a HEAD-nél kihúzza a szülőkre mutató éleket. Ami ezen a határon túl van, az a klón szempontjából nem létezik. És itt jön a kellemetlen rész: a shallow clone nem hibát dob, hanem csendben mást válaszol. A git log -- <fájl> a react-es shallow klónomban egyetlen commitot mutatott egy 313 revíziós fájlra – mintha a fájl tegnap született volna. A HEAD~1 nem érvényes objektumnév. A git describe --tags pedig fatal-lal áll le, mert tag egyáltalán nem jött le. Egy changelog-szkript ezekből az első esetben rossz eredményt gyárt, nem hibaüzenetet – ez a fajta hallgatag hazugság rosszabb, mint egy tisztességes crash.

A másik, kevésbé ismert gond a fetch. A GitHub mérnökei a saját blogjukon írják le feketén-fehéren: „Never fetch from a shallow clone!” A shallow határt a kliens minden fetchnél elküldi, a szervernek pedig ehhez képest kell kiszámolnia, mi az új – a szokásos gyorsítások (például a reachability bitmapek) nélkül. Rossz esetben egy ártatlan git fetch egy majdnem teljes klónnyi adatot zúdít le. Eldobható, egyszeri buildhez a shallow clone tökéletes; bármihez, ami utána még fetchel, kifejezetten ellenjavallt.

Hol fáj a blobless clone: a blame

Persze a partial clone sem ingyenebéd, és a számla akkor érkezik, amikor a hiányzó objektumokra mégis szükség lesz. A klónozáskor a Git beállítja az origint úgynevezett promisor remote-nak (remote.origin.promisor=true): ő ígérte meg, hogy a kihagyott objektumokat kérésre bármikor odaadja. A git checkout ügyesen, egyetlen batch-kérésben szedi le az adott commithoz kellő blobokat. A git blame viszont nem tud előre: revíziónként lépked visszafelé, és minden lépésnél akkor szembesül a hiányzó blobbal, amikor odaér.

Megmértem ugyanazt a git blame-et a react egyik legtöbbet módosított fájljára (ReactFiberWorkLoop.js, 313 revízió) mindkét klónban. A teljes klónban 0,44 másodperc. A bloblessben 330 másodperc – öt és fél perc, 750-szeres különbség –, és közben a .git/objects/pack könyvtár 2 packfile-ról 45-re hízott, mert a lusta fetchek egymás után, külön hálózati körökben mentek, és mindegyik külön packfile-t hozott létre. Na, ezt előre nem gondoltam volna: nem az adatmennyiség öl meg (összesen 4 MB jött le), hanem a soros round-tripek.

A jó hír, hogy ez egyszeri költség – a letöltött blob helyben marad, a második blame már lokális sebességgel fut. A Git csapata is tudja, hogy ez a leggyengébb pont: a 2.49-es kiadás óta létezik a git backfill parancs, ami pont ezt a sebet kezeli – a meglévő tree-kből összegyűjti a hiányzó blobokat, és nagy batchekben, kevés round-trippel tölti le őket, akár a sparse-checkout definícióra szűkítve. Ha blobless klónban dolgozol és blame-elni készülsz, ezt érdemes egyszer lefuttatni előre.

És a treeless?

A --filter=tree:0 csábítóan kicsi, de sokkal szűkebb pályán mozog. Amíg csak commit-adat kell (git log útvonal nélkül, merge-base), addig minden rendben. De egy git log -- <fájl> már tree-letöltési lavinát indít – a GitHub tapasztalata szerint gyakorlatilag minden commithoz le kell kérni a root tree-t, ráadásul submodule-os repónál egy sima git fetch is commitonkénti tree-kérésekbe fullad. A treeless clone arra való, amire a shallow: egyszeri build, aztán kuka – csak épp úgy, hogy közben a commit-history alapú lépéseid (verziószám-számítás, merge-base a PR diffhez) is működnek.

Mit állítsak be a CI-ban?

Az actions/checkout natívan támogatja a filtert, úgyhogy a „kell a history” eseteknél a fetch-depth: 0 mellé egyetlen sor:

- uses: actions/checkout@v5
  with:
    fetch-depth: 0
    filter: blob:none

A saját ökölszabályaim a mérés után: eldobható build, aminek nem kell history – marad a default shallow, az a leggyorsabb, és a hírhedt fetch-probléma egyszeri klónnál nem játszik. Minden, ami historyt akar (release-tooling, blame-alapú elemzés, changelog): blob:none, és soha többé fetch-depth: 0 csupaszon. Fejlesztői gépre normál méretű repónál továbbra is a teljes klón a kényelmes – offline is működik, és a blame se lassul –, de egy sokgigás monorepónál a blobless clone-nal kezdenék, ahogy a GitHub is ezt ajánlja belépőnek. A treeless módot élő munkapéldányban hagynám meg annak, ami: érdekes lábjegyzetnek.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük