Schnellere CI-Builds: Git Checkout gezielt optimieren

8 Minuten zum Lesen
Schnellere CI-Builds: Git Checkout gezielt optimieren
Hier erfährst du, wie Shallow Fetch, Blob-Filter und Sparse Checkout CI-Builds deutlich beschleunigen und wann jede Option die richtige Wahl ist.

Ein Repository wächst mit jedem Commit. Irgendwann landen Binaries in der History, Libraries werden direkt commitet, oder das Projekt entwickelt sich zu einem Monorepo mit Dutzenden von Anwendungen. Ab diesem Punkt werden CI-Pipelines (Continuous Integration) spürbar langsamer, nicht wegen des eigentlichen Builds, sondern wegen des Git-Checkouts am Anfang. Was wie ein technisches Detail klingt, summiert sich bei mehreren Pipelines und Stages schnell auf viele Minuten verschwendeter CI-Zeit pro Commit.

Standardmäßig klont Git beim CI-Checkout die vollständige History, alle Dateiinhalte aller Versionen und den gesamten Working Tree. Mit drei gezielten Optimierungen lässt sich das deutlich reduzieren: Shallow Fetch für die History, Fetch-Filters für die Dateiinhalte und Sparse Checkout für den Working Tree.

Im besten Fall verwendest du auch gleich Git LFS (Large File Storage) für große Dateien, damit die Blobs gar nicht erst im Repository landen. Mehr dazu in unserem Artikel: Git: Attributes, Ignore und LFS.

Drei Faktoren, die den Checkout bremsen

Jeder Git-Clone überträgt im Standard drei Dinge: die History (alle Commits und Trees aller Branches), die Blobs (Dateiinhalte aller Versionen) und den Working Tree (die auf Disk geschriebenen Dateien). Jeder dieser Faktoren lässt sich unabhängig optimieren.

FaktorWas darin stecktLösung
HistoryAlle Commits und Trees aller BranchesShallow Fetch (fetchDepth: 1)
BlobsDateiinhalte aller VersionenFetch Filter (blob:none)
Working TreeAlle ausgecheckten Dateien inkl. irrelevanter PfadeSparse Checkout

Die drei Optionen lassen sich auch kombinieren. Ein Build-Job, der nur kompiliert und Tests ausführt, braucht weder vollständige History noch Dateien aus anderen Projekten des Monorepos. Welche Kombination sinnvoll ist, hängt davon ab, was der jeweilige Job tatsächlich liest.

Shallow Fetch

Mit --depth N holt Git beim Clone nur die letzten N Commits. In GitHub Actions ist fetch-depth: 1 bereits der Standard in actions/checkout@v4. In Azure Pipelines ist der Default abhängig wann die Pipeline angelegt wurde. Der Standard für YAML-Pipelines ist hier auch 1. Die offizielle Parameterliste von actions/checkout und die Checkout-Task-Dokumentation für Azure Pipelines beschreiben alle verfügbaren Optionen.

# GitHub Actions – nur letzter Commit (ist in actions/checkout@v4 bereits der Default)
- uses: actions/checkout@v4
  with:
    fetch-depth: 1

# GitHub Actions – vollständige History
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
# Azure Pipelines – nur letzter Commit
steps:
  - checkout: self
    fetchDepth: 1

# Azure Pipelines – vollständige History
steps:
  - checkout: self
    fetchDepth: 0

fetch-depth: 0 bzw. fetchDepth: 0 ist nötig für Jobs, die git log, Changelog-Tools oder Merge-Base-Berechnungen verwenden.

Wenn Diffs fehlschlagen

Ein häufiges Problem taucht bei Pull-Request-Validierungen (PR) auf: git diff origin/main..HEAD liefert ein leeres oder falsches Ergebnis, weil ein flacher Clone keinen gemeinsamen Vorfahren kennt. Auch git merge-base origin/main HEAD schlägt dann mit no merge base found fehl. Der GitHub Blog-Artikel zu Partial Clone und Shallow Clone erklärt, warum das so ist.

# Reproduktion des Problems: leeres oder falsches Ergebnis nach flachem Clone
git clone --depth 1 https://github.com/org/repo.git
cd repo
git fetch origin main --depth 1
git diff origin/main..HEAD       # schlägt fehl oder liefert falschen Output
git merge-base origin/main HEAD  # Error: "no merge base found"

Ein gezieltes git fetch --unshallow vor dem Diff-Schritt löst das, ohne die gesamte Pipeline auf fetchDepth: 0 umstellen zu müssen. Die offizielle git-fetch-Dokumentation beschreibt --unshallow ausführlich.

# Azure Pipelines – Unshallow nur bei Pull Request Builds
steps:
  - checkout: self
    fetchDepth: 1
  - script: git fetch --unshallow
    displayName: 'Unshallow for diff detection'
    condition: eq(variables['Build.Reason'], 'PullRequest')

Dieser Ansatz ist auch relevant, wenn du in Pipelines auf Dateiänderungen prüfst, um Stages bedingt auszuführen. Wie das im Detail funktioniert, schau in unseren Artikel: Azure Pipelines: Stages nur bei relevanten Dateiänderungen ausführen.

Blob-Filter blob:none

Beim blobless Clone mit --filter=blob:none überträgt Git sofort alle Commits und Trees, lädt Blob-Inhalte aber erst beim tatsächlichen Lesen nach. Für Jobs, die primär mit dem Git-Graphen arbeiten, also z.B. Versionsnummern aus Tags lesen oder Changelogs generieren, spart das erheblich Netzwerkzeit. Die offizielle Dokumentation zu Partial Clone beschreibt den Filtermechanismus im Detail.

# GitHub Actions – blobless Clone mit vollständiger History
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    filter: blob:none
# Azure Pipelines – blobless Clone (erfordert Git ≥ 2.27 auf dem Agent)
steps:
  - checkout: self
    fetchDepth: 0
    fetchFilter: blob:none

blob:none ist kein Ersatz für Sparse Checkout. Wenn der Build viele Dateien liest, lädt Git die Blobs on demand nach, was bei großen Projekten viele einzelne Requests erzeugt. Für Self-Hosted Agents empfiehlt sich außerdem protocol.version=2 (Git Wire Protocol v2, das Partial-Clone-Filter effizienter überträgt), damit das Filter-Protokoll effizient funktioniert.

Sparse Checkout: Cone und Non-Cone

Sparse Checkout begrenzt den Working Tree auf ausgewählte Pfade. Das reduziert Disk I/O erheblich, besonders in Monorepos, in denen ein einzelner Build-Job nur einen Teilbaum des Repositories benötigt. Eine ausführliche Einführung mit Vergleich beider Modi bietet der GitHub Blog-Artikel zu Sparse Checkout in Monorepos.

Git bietet zwei Modi, beschrieben in der offiziellen git sparse-checkout Referenz:

  • Cone Mode (Standard seit Git 2.26): Pattern entsprechen Verzeichnissen und werden per Prefix-Matching auf Tree-Ebene ausgewertet. Schnell und für die meisten CI-Jobs ausreichend.
  • Non-Cone Mode: Glob-Patterns ähnlich wie in .gitignore. Flexibler, aber langsamer bei großen Trees.
Cone ModeNon-Cone Mode
Pattern-TypVerzeichnispfadeGlob-Patterns
GeschwindigkeitSchnell (Prefix-Match auf Tree-Ebene)Langsamer (Pattern-Match je Datei)
GranularitätGanze VerzeichnisseBeliebige Pfade und Dateien
EmpfehlungFür die meisten CI-JobsWenn Feingranularität nötig
# Cone Mode – nur src/ und docs/ auschecken
git clone --no-checkout https://github.com/org/repo.git
cd repo
git sparse-checkout init --cone
git sparse-checkout set src/app docs
git checkout main

Cone Mode – Was wird ausgecheckt: Alle Dateien und Verzeichnisse unter src/app und docs/, plus Root-Dateien:

.
├── README.md              ✓ (Root-Datei, Parent von src/ und docs/)
├── package.json           ✓ (Root-Datei, Parent von src/ und docs/)
├── src/
│   ├── app/
│       └── modules/       ✓ (matched 'src/app')
│           └── index.ts   ✓ (matched 'src/app')
│       └── index.ts       ✓ (matched 'src/app')
│   ├── service/           ✗ (wird NICHT ausgecheckt)
│   │   └── api.ts         ✗
│   └── utils/             ✗ (wird NICHT ausgecheckt)
│       └── helper.ts      ✗
├── docs/
│   ├── guide.md           ✓ (matched 'docs')
│   └── api.md             ✓ (matched 'docs')
│   └── development/       ✓ (matched 'docs')
│       └── onboarding.md  ✓ (matched 'docs')
└── tests/                 ✗ (wird NICHT ausgecheckt)
    └── unit.test.ts       ✗
# Non-Cone Mode – präzisere Pfad-Selektion
git clone --no-checkout https://github.com/org/repo.git
cd repo
git sparse-checkout init   # kein --cone
git sparse-checkout set 'src/app/**' 'docs/*'
git checkout main

Non-Cone Mode – Was wird ausgecheckt: Nur die exakten Patterns; src/app/** bedeutet alles unter app/, aber nicht src/ selbst oder src/service/:

.
├── README.md              ✗ (Root-Datei nicht ausgecheckt)
├── package.json           ✗ (Root-Datei nicht ausgecheckt)
├── src/
│   ├── app/
│       └── modules/       ✓ (matched 'src/app/**')
│           └── index.ts   ✓ (matched 'src/app/**')
│       └── index.ts       ✓ (matched 'src/app/**')
│   ├── service/           ✗ (wird NICHT ausgecheckt)
│   │   └── api.ts         ✗
│   └── utils/             ✗ (wird NICHT ausgecheckt)
│       └── helper.ts      ✗
├── docs/
│   ├── guide.md           ✓ (matched 'docs/*')
│   └── api.md             ✓ (matched 'docs/*')
│   └── development/       ✗ (wird NICHT ausgecheckt)
│       └── onboarding.md  ✗
└── tests/                 ✗ (wird NICHT ausgecheckt)
    └── unit.test.ts       ✗

Sowohl GitHub Actions als auch Azure DevOps unterstützen Sparse Checkout sowohl im Cone- als auch im Non-Cone Mode direkt in ihren Checkout-Tasks. Beide können in Verbindung mit Fetch Depth und auch Fetch Filter verwendet werden, um die Checkout-Zeit weiter zu reduzieren.

# GitHub Actions
- uses: actions/checkout@v4
  with:
    fetch-depth: 1
    filter: blob:none
    sparse-checkout: |
      src
      docs
    sprase-checkout-cone-mode: true # Cone Mode (Default true, sonst non-cone mode)
# Azure Pipelines
steps:
  - checkout: self
    fetchDepth: 1
    fetchFilter: blob:none
    # Cone-mode, prio over sparseCheckoutPatterns
    sparseCheckoutDirectories: |
      src
      docs
    # None-cone mode, ignored if sparseCheckoutDirectories is set
    sparseCheckoutPatterns: | 
      src/app/**
      docs/*

Fallstricke kennen

Bevor du alles auf fetchDepth: 1 umstellst, gibt es einige Szenarien zu beachten.

Tools mit History-Bedarf: Semantic Release, Conventional Changelog und ähnliche Tools benötigen die vollständige History ab dem letzten Git-Tag. Für solche Jobs ist fetch-depth: 0 oder git fetch --shallow-since=<last-tag-date> nötig. Es empfiehlt sich, dafür einen dedizierten Release-Job zu definieren, der explizit die vollständige History holt, anstatt alle Build-Jobs darauf zu verpflichten.

Git-Versionen auf Self-Hosted Agents: blob:none erfordert Git ≥ 2.27, der Cone Mode ≥ 2.26. Auf älteren Agents lässt sich die Version mit git --version prüfen. Als Fallback für ältere Versionen ist eine manuelle Konfiguration über .git/info/sparse-checkout und core.sparseCheckout=true möglich.

Sparse Checkout ist kein Sicherheitsmerkmal: Ausgesparte Pfade fehlen im Working Tree, sind aber weiterhin in der Git-Datenbank vorhanden. Sparse Checkout reduziert, was auf Disk landet, verhindert aber keinen Zugriff auf die Git-Objekte selbst.

CI-Caches mit Git-State: Wenn .git per actions/cache zwischengespeichert wird, kann ein nachfolgendes git fetch --unshallow den Cache-State veralten lassen. Den Cache-Key so wählen, dass er den aktuellen Checkout-Zustand abbildet, oder .git vom Caching ausschließen.

Zur schnellen Diagnose nach dem Checkout helfen diese Kommandos:

# Wie viele Commits wurden geladen?
git rev-list --count HEAD

# Welche Pfade sind aktiv ausgecheckt?
git sparse-checkout list

# Wurde die Merge-Base korrekt gefunden?
git merge-base origin/main HEAD

Empfehlungen auf einen Blick

Die drei Optimierungen greifen an unterschiedlichen Stellen und ergänzen sich gut. Als Orientierung:

  • fetchDepth: 1 als Standard für alle einfachen Build- und Test-Jobs
  • fetch-depth: 0 / fetchDepth: 0 nur für Jobs mit History-Bedarf (Release, Changelog, Diff-basierte Analysen)
  • blob:none ergänzen, wenn Commits und Trees benötigt werden, aber nicht alle Dateiinhalte sofort gelesen werden
  • Sparse Checkout im Cone Mode für Monorepos aktivieren, in denen der Job nur einen Teilbaum benötigt
  • git fetch --unshallow vor Diff-Schritten einplanen, wenn fetchDepth: 1 gesetzt ist

Eine nützliche Ergänzung ist ein Logging-Schritt am Anfang der Pipeline, der die aktuelle Checkout-Konfiguration dokumentiert und Veränderungen über Zeit sichtbar macht:

- script: |
    echo "Git repo size: $(du -sh .git | cut -f1)"
    echo "Commits fetched: $(git rev-list --count HEAD)"
    echo "Checked-out files: $(git ls-files | wc -l)"
  displayName: 'Checkout metrics'

Die Werte lassen sich über Zeit beobachten und geben schnell Hinweise, wenn ein Commit die Checkout-Zeit erheblich verändert.

Wie optimierst du den Git-Checkout in deinen CI-Pipelines? Schau gerne bei uns auf LinkedIn vorbei und diskutiere mit.

Autoren

Florian Bader

Florian Bader

Florian ist Solution Architect und Microsoft Most Valuable Professional (MVP) für Azure IoT und DevOps mit langjähriger Erfahrung im Bereich DevOps, Cloud und Digitalisierung. Er unterstützt Unternehmen dabei, effiziente und effektive Lösungen zu entwickeln, die ihre digitalen Projekte nachhaltig zum Erfolg führen.