Git-LFS-Unterstützung für Ghost
14/10/2026
Wie Git LFS funktioniert und wie ich die Unterstützung dafür in Ghost eingebaut habe
Hinweis: Dieser Text wurde von einem LLM aus dem Englischen übersetzt. Maßgeblich ist die englische Originalfassung – bei Ungereimtheiten in Formulierung oder Fachbegriffen bitte dort nachsehen.
Dieser Blogpost gehört zur Reihe des zuvor veröffentlichten Blogposts über das Bauen einer Git-Plattform. Ausprobieren kannst du die Anwendung unter web.ghost.procrastinator.fyi.
Ein Punkt von vielen auf der Roadmap war die Unterstützung für Git LFS. Mit Git LFS kannst du große Dateien wie Audio, Video usw. speichern, wobei Git nicht die Datei selbst speichert, sondern einen Pointer auf die Datei auf dem Server.
Die Authentifizierung wurde im vorherigen Blogpost behandelt. LFS nutzt dieselbe Spezifikation.
Git LFS läuft ebenfalls über HTTP, mit den folgenden Endpunkten:
| Route | Macht |
|---|---|
POST /<owner>/<repo>.git/info/lfs/objects/batch | Welche Objekte hochgeladen werden sollen bzw. woher jedes heruntergeladen wird |
PUT …/info/lfs/objects/<oid> | Speichert ein Objekt |
GET …/info/lfs/objects/<oid> | Liefert ein Objekt aus |
GET, POST …/info/lfs/locks, …/locks/verify, …/locks/<id>/unlock | Advisory Locks |
In deinem .git-Ordner liegen die LFS-Objekte unter .git/lfs/objects/
POST /info/lfs/objects/batch
Diese Route liefert application/vnd.git-lfs+json als Content-Type, was technisch gesehen einfach JSON ist. Der Body sieht so aus:
class-validator und ich persönlich liebe es
Die Response dazu sieht so aus:
Bei Fehlern wird actions des Objekts durch ein Feld error: {code, message} ersetzt. Fehler gelten pro Objekt, eine fehlende Datei lässt also nicht den ganzen Batch scheitern. Ein download für ein Objekt, das das Repository nicht hat, bekommt ein 404, ein upload über dem Größenlimit ein 422. Und ein upload für ein Objekt, das der Server bereits hat, kommt ganz ohne actions zurück – das ist für git-lfs das Zeichen, es zu überspringen.
Beachte den header in jeder Action. Ich kopiere einfach den eigenen Authorization-Header des Requests hinein, damit git-lfs die Zugangsdaten nicht ein zweites Mal nachschlagen muss.
Je nachdem, welchen Befehl du ausgeführt hast, lädt git das Objekt dann von bzw. zu dem angegebenen href mit den angegebenen Headern herunter oder hoch.
PUT …/info/lfs/objects/<oid>
Beim PUT-Request schickt git-lfs die eigentlichen Bytes eines Objekts. Der Body ist die rohe Datei, Content-Length ist Pflicht, und die oid in der URL ist der SHA-256 des Dateiinhalts.
Die naive Version lautet: „Body nach S3 unter lfs/<repositoryId>/<oid> streamen, fertig.“ Das Problem: Der Client teilt mir den Hash mit, und das könnte ein böswilliger Akteur mit irgendeinem zufälligen Hash sein. Würde ich direkt auf den Key des Objekts schreiben, könnte jeder mit Schreibrechten Müll unter einer oid hochladen und die echten Bytes überschreiben, die ein anderer Upload bereits gespeichert hat.
Deshalb wird ein Upload verifiziert:
- Speicherkontingent reservieren und eine
lfs_object-Zeile einfügen, unter dem Lock des Accounts. Die Zeile hat noch keinuploadedAt, ist also nur eine Reservierung. - Die Bytes auf einen Staging-Key streamen,
lfs/<repositoryId>/uploads/<uploadId>, und sie dabei hashen. - Nur wenn der Hash zur oid passt, das Staging-Objekt nach
lfs/<repositoryId>/<oid>kopieren unduploadedAtsetzen. Andernfalls die Zeile löschen und mit einem422abbrechen.
Das Staging-Objekt wird in jedem Fall in einem finally gelöscht.
uploads kann nie eine gültige oid sein (eine oid besteht aus 64 kleingeschriebenen Hex-Zeichen), daher teilen sich Staging- und gespeicherte Objekte das Präfix des Repositorys, ohne je zu kollidieren.
Ein paar Edge Cases:
- Zwei gleichzeitige Uploads derselben oid. Der Insert ist
onConflictDoNothing(), der zweite bekommt also ein409 Conflict. - Ein Upload, der mit seinem Prozess gestorben ist. Er hinterlässt für immer eine Reservierungszeile. Deshalb lösche ich vor dem Einfügen jede Reservierung derselben oid, die älter als die Reservierungs-TTL ist.
- Die oid wird Teil eines S3-Keys. Deshalb wird sie vor allem anderen gegen
/^[0-9a-f]{64}$/geprüft. Nur SHA-256 wird akzeptiert. - Größe. Ein Objekt ist höchstens 5 GiB groß, das ist das S3-Limit für ein einzelnes
PutObject.
GET …/info/lfs/objects/<oid>
Die langweilige Route. Zeile nachschlagen, sicherstellen, dass uploadedAt gesetzt ist, und das Objekt aus S3 zurückstreamen, mit gesetztem Content-Length und application/octet-stream als Typ. Die Bytes gehen in beide Richtungen durch die API und nie direkt aus dem Bucket, damit für alles dieselbe Authentifizierung gilt.
Authentifizierung
Dieselbe Basic-Auth-Middleware aus dem vorherigen Post autorisiert all diese Routen. Uploads und Lock-Änderungen brauchen Schreibrechte, Downloads Leserechte.
Es gibt einen Haken. Bei gits eigenen Routen kann ich einen Push von einem Fetch anhand des Pfads unterscheiden. Bei LFS nennt der Batch-Request seine Operation (upload oder download) im Body. Der Batch-Body muss also vor der Auth-Middleware geparst werden:
Über SSH hat git-lfs kein Passwort, das es mitschicken könnte. Stattdessen führt es auf dem Server git-lfs-authenticate <repo> <operation> aus, das den HTTPS-Endpunkt und einen zu verwendenden Header ausgibt. Ghost antwortet mit einem Bearer-Token: einem HMAC-signierten Payload aus { userId, repositoryId, operation, expiresAt }, gültig für eine Stunde. Die Middleware akzeptiert ihn nur auf den LFS-Routen genau dieses einen Repositorys, und für Schreibzugriffe nur, wenn er für einen upload ausgestellt wurde.
Locks
Git LFS hat außerdem eine Locking-API für Dateien, die sich nicht mergen lassen, etwa eine Photoshop-Datei. Du führst git lfs lock design.psd aus, und alle anderen wissen, dass sie die Finger davon lassen sollen.
| Route | Macht |
|---|---|
GET …/locks | Listet Locks auf, filterbar nach path oder id, paginiert |
POST …/locks | Lockt einen Pfad. 409 mit dem bestehenden Lock, falls er schon vergeben ist |
POST …/locks/verify | Vor einem Push: welche Locks ours sind, welche theirs |
POST …/locks/<id>/unlock | Entsperrt. Nur der Besitzer darf das, oder ein Admin mit force: true |
Locks sind Zeilen in einer lfs_lock-Tabelle, mit (repositoryId, path) als Unique-Constraint. Diese Eindeutigkeit dient gleichzeitig als Pagination-Cursor: Locks sind nach Pfad sortiert, die nächste Seite ist also einfach „Pfade größer als der letzte“.
Sie sind advisory, genau wie bei GitHub. git-lfs prüft sie vor einem Push. Der Server lehnt keinen Push ab, der die gelockte Datei von jemand anderem anfasst.
Speicherung
Objekte werden pro Repository gespeichert, unter lfs/<repositoryId>/<oid>, mit jeweils einer lfs_object-Zeile. Ein Objekt ist genau dort lesbar, wo auch sein Repository lesbar ist.
Ich habe über einen gemeinsamen, inhaltsadressierten Speicher nachgedacht. Gleiche oid, gleiche Bytes – warum also zwei Kopien behalten? Weil:
- das Löschen von irgendetwas Reference Counting bräuchte, und
- jeder, der eine oid kennt, sie in seinem eigenen Repository beanspruchen könnte, ohne sie je hochgeladen zu haben. Einen Hash zu kennen sollte nicht reichen, um die Datei zu lesen.
Der Preis dafür ist Kopieren. Forken kopiert jedes Objekt des Quell-Repositorys (serverseitig mit S3s CopyObject, die Bytes verlassen den Bucket also nie), abgerechnet auf den Fork. Das Mergen eines Pull Requests aus einem Fork kopiert nur die Objekte, auf die Pointer-Dateien in den neuen Commits des Heads verweisen und die die Base noch nicht hat. Um die zu finden, läuft ein git rev-list --objects über die neuen Commits, gefiltert auf Blobs unter 1025 Bytes (eine Pointer-Datei ist auf 1024 begrenzt), und anschließend wird jeder Blob als Pointer geparst:
Im Web
Ohne all das würde dir eine LFS-Datei im Browser einfach die drei Zeilen oben anzeigen. Deshalb prüfen die Blob- und Raw-Endpunkte, ob eine Datei ein Pointer ist, und liefern, falls das Repository das Objekt hat, stattdessen das Objekt aus. Außerdem geben sie lfs: stored | missing | null an, damit die UI dir sagen kann, wenn zwar der Pointer da ist, aber niemand die eigentliche Datei gepusht hat.
Das war's. git lfs install, git lfs track "*.mp4", pushen, und es funktioniert einfach.