Git Bundle & Shallow / Partial Clone: Air-Gapped CI & Bandwidth-Constrained Pipelines¶
git bundle packs a repository (or subset of refs) into a single portable file — ideal for air-gapped transfers, offline CI runners, disaster recovery snapshots, and secure one-way pushes through restricted networks. git clone --depth and partial clone filters dramatically reduce bandwidth and disk usage for massive monorepos on CI.
Quick Reference¶
| Action | Command | When to Use |
|---|---|---|
| Bundle entire repo | git bundle create backup.bundle --all |
Full offline mirror / DR snapshot |
| Bundle specific refs | git bundle create HEAD.bundle HEAD main develop |
Selective push through restricted network |
| Verify a bundle | git bundle verify backup.bundle |
Confirm bundle is self-contained before transfer |
| List refs in bundle | git bundle list-heads backup.bundle |
Inspect what a bundle contains |
| Clone from bundle | git clone backup.bundle ./repo |
Offline clone / restore from bundle |
| Pull from bundle | git pull bundle.bundle main |
Merge bundle into existing repo |
| Shallow clone | git clone --depth 50 --branch main <url> |
CI runner with limited bandwidth |
| Incremental shallow fetch | git fetch --depth 100 |
Extend shallow history on CI |
| Full history | git fetch --unshallow |
When a late pipeline stage needs full history |
| Partial clone (no blobs) | git clone --filter=blob:none <url> |
Large monorepo where you only need source tree |
| Sparse checkout | git sparse-checkout set src/app build/ |
Only needed directories in partial clone |
Part 1: git bundle — Offline Transfer and CI Backup¶
Why Bundle?¶
Most CI systems fetch directly from a Git server. But production environments with DMZ isolation, on-premise Forgejo/Gitea behind a VPN, or air-gapped datacenters cannot reach upstream. git bundle solves this by serialising Git objects and refs into a single file that can be transferred via SCP, USB, S3 pre-signed URL, or any file-based mechanism.
Common DevOps scenarios:
- DR snapshot: Bundle a production repo before a risky migration or major refactor; store the .bundle file in object storage.
- One-way sync through a jump host: git bundle create on the inside, transfer via SCP to the DMZ, then git pull bundle.bundle on the air-gapped runner.
- Offline code review: Bundle a feature branch and all dependent commits to share without exposing the full repo.
- CI artifact: In a pipeline that cannot reach the Git server (network policy), the previous job bundles --all, publishes as a build artifact, and the next job clones from it.
Creating a Bundle¶
# Full repository bundle (all branches + tags)
git bundle create /backups/repo-$(date +%Y%m%d).bundle --all
# Selective: only the refs you need for the CI job
git bundle create ci-input.bundle HEAD main release/2.4.1
# Incremental: bundle only commits since the last run
git bundle create incremental.bundle ^last-build-commit HEAD main
The ^<commit> <refspec> syntax means "everything reachable from <refspec> but not from <commit>". Use git rev-parse last-build-commit to get the SHA from the previous pipeline run's env var or artifact.
Verifying and Inspecting a Bundle¶
Always verify before transferring — a corrupted or incomplete bundle wastes time on an air-gapped system.
# Check the bundle is self-contained and covers the claimed refs
git bundle verify /path/to/repo.bundle
# Exit 0: bundle is complete and valid
# Exit 1: refs are missing or bundle is corrupt
# List all refs packed into the bundle
git bundle list-heads /path/to/repo.bundle
Cloning from / Pulling into a Bundle¶
# Fresh clone from bundle
git clone /backups/repo-$(date +%Y%m%d).bundle ./workspace
# Clone to a specific branch (if bundle only contains one branch)
git clone -b main /backups/repo-$(date +%Y%m%d).bundle ./workspace
# Pull/merge bundle into an existing repo (common in CI chains)
cd existing-repo
git pull /path/to/ci-input.bundle main
CI Pipeline Example: Two-Job Air-Gapped Setup¶
Job 1 (has Git server access):
#!/bin/bash
set -euo pipefail
# Create bundle of the trigger commit and main branch
GIT_COMMIT=$(git rev-parse HEAD)
git bundle create repo.bundle --tagged-refs \
HEAD "$GIT_COMMIT" main origin/main
# Upload as pipeline artifact (e.g., Azure Pipelines, GitHub Actions, etc.)
# In GitHub Actions:
echo "artifact_name=repo" >> $GITHUB_ENV
Job 2 (air-gapped runner — no Git server access):
#!/bin/bash
set -euo pipefail
# Download artifact: repo.bundle
# Then:
git clone repo.bundle ./app
cd app
git bundle verify repo.bundle # sanity check
npm ci
npm run build
Bundle Size and Object Packing¶
Bundles are thin on first creation (they store all reachable objects). Use git repack before bundling for maximum portability:
git bundle create clean.bundle --all
git clone clean.bundle /tmp/check
cd /tmp/check && git repack -ad
# Re-bundle for a smaller file:
git bundle create ../final.bundle --all
Part 2: Shallow Clone — Speed for CI Runners¶
--depth: Fixed History Depth¶
# Clone with last 50 commits on main
git clone --depth 50 --branch main https://git.internal/repo.git ./app
# Verify depth
cd ./app
git log --oneline | wc -l # should be ~50
# Check total object count saved
git count-objects -vH
--single-branch (also --no-single-branch)¶
# Only history needed for this branch (default with --depth)
git clone --depth 1 --branch main https://git.internal/repo.git ./app
# Clone a single branch without depth limit (no history merge commits)
git clone --single-branch --branch main https://git.internal/repo.git ./app
--branch with Tags¶
# Shallow clone a specific release tag (no all-branches history)
git clone --depth 1 --branch v2.4.1 https://git.internal/repo.git ./release
Extending a Shallow Clone¶
# Fetch more history (e.g., from 50 to 200 commits)
git fetch --depth 200
# Fetch all remaining history
git fetch --unshallow
Warning: --unshallow on a shallow clone that contains merge commits may fail with fatal: refusing to merge unrelated histories. Resolve with git pull --allow-unrelated-histories or re-clone without --depth if full history is truly needed.
Shallow Clone in CI: Makefile / Script Pattern¶
# In Dockerfile / CI script
GIT_DEPTH="${GIT_DEPTH:-50}"
git clone --depth "$GIT_DEPTH" --branch "${GIT_BRANCH:-main}" \
"$REPO_URL" /app
cd /app
# For pipelines that need full history only in certain stages:
# Stage 1: shallow (fast install/build)
# Stage N: fetch full history only when needed
if [ "$NEED_FULL_HISTORY" = "1" ]; then
git fetch --unshallow
fi
Part 3: Partial Clone — Monorepo Without the Weight¶
For repos over 1 GB, shallow clone alone is insufficient. git clone --filter downloads only the Git object graph (commits, trees), leaving blobs (file content) to be fetched on demand.
--filter=blob:none — Tree-Only Clone¶
git clone --filter=blob:none https://git.internal/monorepo.git ./app
# Downloads commit graph and directory structure; file content fetched lazily
# Typical size reduction: 60–90% for repos with large binary assets
--filter=blob:limit=<n>[kmg] — Filter by Size¶
# Exclude blobs larger than 10 MB from initial download
git clone --filter=blob:limit=10m https://git.internal/monorepo.git ./app
Sparse Checkout — Only the Directory You Need¶
Partial clone fetches the full tree. Sparse checkout limits the working directory further:
# After partial clone:
cd ./monorepo
# Only check out src/ and build/ directories (no lib/, docs/, etc.)
git sparse-checkout init --cone
git sparse-checkout set src/app build/ terraform/
# Verify sparse checkout
git sparse-checkout list
# Output: src/app build terraform
For non-cone mode (glob patterns):
Combining Filters¶
# Partial clone + shallow depth + sparse checkout = minimum CI footprint
git clone \
--filter=blob:none \
--depth 30 \
--branch main \
--sparse \
https://git.internal/monorepo.git ./app
cd ./app
git sparse-checkout set src/api tests/e2e
Lazy Blob Fetch¶
When a partial clone is checked out, Git fetches blobs on first access (e.g., opening a file in an editor). For CI build scripts that need specific files:
# Force-fetch a specific directory's files (avoids on-demand stalls during build)
git fetch --filter=blob:none --no-recurse-submodules origin -- src/frontend
# Or use rev-list to determine what blobs you need and pre-fetch them:
git rev-list --objects HEAD -- src/frontend | \
git fetch-pack --stdin --quantize=0 origin | \
git index-pack --stdin
Part 4: Performance Comparison and Decision Tree¶
| Strategy | Network Transfer | Disk Usage | History Available | Best For |
|---|---|---|---|---|
| Full clone | 100% of repo | Full | Yes | Local dev, release builds |
Shallow clone --depth N |
~N× avg commit size | Minimal | Last N commits | CI install/test steps |
Partial --filter=blob:none |
Commit graph only | ~5–20% | Yes | Monorepo CI build |
| Partial + sparse checkout | Commit graph + subset | ~1–10% | Yes | Mega-monorepo (NxN builds) |
| Bundle (full) | ~full repo size (once) | Full | Yes | Offline/air-gap CI |
| Bundle (incremental) | Diff only | Full | Yes | Frequent air-gap sync |
Decision tree:
1. Can the runner reach the Git server? → No → Use git bundle
2. Is the repo > 500 MB? → Yes → Use --filter=blob:none
3. Does the pipeline only need one subdirectory? → Yes → Add git sparse-checkout set
4. Is build speed critical? → Yes → Add --depth 50 (adjust to minimum viable)
5. Does a later stage need full history? → Yes → Plan --unshallow only for that stage
Security Considerations¶
- Bundles are not encrypted. Transport via HTTPS, SCP with key-based auth, or encrypt with
gpg --encryptbefore transfer if the bundle contains secrets (e.g., if a dev accidentally bundled a repo with.envfiles). - Verify bundle signatures if bundled from an untrusted source:
git bundle verifyonly checks completeness, not authenticity — use GPG-signed tags alongside the bundle. - Shallow clones reduce attack surface — fewer old commits mean fewer potential references to leaked credentials in commit messages.
- Partial clones do not reduce risk from malicious blobs that may exist in history; use
git filter-repoto scrub history before creating bundles.
Cleanup¶
# Remove bundle files after successful transfer / CI run
rm -f repo.bundle incremental.bundle
# Convert shallow clone to full (after confirming no more incremental fetches needed)
git fetch --unshallow
# Convert partial clone back to full (download all remaining blobs)
git fetch --filter=blob:none --refetch origin
# (refetch downloads all blobs not already present)