
Delivery
Git, GitHub, GitLab: What You Own and What You Rent
Git moves history, refs and tags losslessly. Issues, CI, packages and permissions move only through a lossy importer — the vendors document what it drops.
The usual comparison agrees: Git is the version control system, GitHub and GitLab are platforms on top, here is a table of runners and seat prices. All true, and none of it names what decides a migration's cost: which half of your delivery system is a format and which half a tenancy. Invisible while you stay; the whole project the day you leave.
Git Is a Protocol; the Forge Is a Tenancy
Git is a content-addressed object store plus a wire protocol. Every commit, tree and blob is named by the hash of its own contents, so a copy verifies against itself, not against the server that sent it, and any implementation of the protocol can read it. A forge is the application wrapped around that repository, with its own data model: issues, pull or merge requests, CI definitions and run history, artifacts, registries, rulesets, webhooks, runner minutes — none of it in Git. GitHub, GitLab and Bitbucket are three tenancies over one protocol whose models do not agree.
The vendors' own tooling makes that concrete. GitHub Enterprise Importer publishes its supported sources — "Azure DevOps (ADO) Cloud, Bitbucket Server and Bitbucket Data Center 5.14+, GitHub.com, GitHub Enterprise Server (GHES) 3.4.1+, GitLab (GitLab.com or maintained self-hosted versions)" — and states that it "does not currently support migrations from GHE.com." A repository is universally readable. A forge's contents need a named, versioned, directional importer — and even the vendor's own has directions it will not go.
The seam between the layers is the push hook. Git documents post-receive as "invoked by git-receive-pack[1] when it reacts to git push and updates reference(s) in its repository," running "once after all the proposed ref updates are processed." That is the extension point a forge replaces with hosted automation. Everything a forge sells happens on the far side of a push, which is why none of it returns when you clone.
What Actually Moves: Refs, Objects, Tags, Signatures
Take the half you own, then prove you have it. git clone --mirror is the instrument: compared with --bare it "not only maps local branches of the source to local branches of the target, it maps all refs (including remote-tracking branches, notes etc.)" All refs, notes included — a copy of the repository, not its default branch.
git bundle create backup.bundle --all gives the same content as one file. Bundles are "used for the 'offline' transfer of Git objects without an active 'server' sitting on the other side of the network connection," and the direction is fixed: "there's no corresponding \"write\" support, i.e. a git push into a bundle is not supported."
But note again that this is only for the refs, i.e. you will only include refs and commits reachable from those refs. You will not include other local state, such as the contents of the index, working tree, the stash, per-repository configuration, hooks, etc.
Read that as an inventory of what a Git-level backup is not. The stash and working tree are nobody's disaster. Hooks are, because hooks are where teams quietly put policy — and Git closes the loop: "client-side hooks are not copied when you clone a repository. If your intent with these scripts is to enforce a policy, you'll probably want to do that on the server side." On a hosted forge, the server side is the vendor's feature surface.
Two things before you trust the copy. Git LFS objects are not in the mirror — GitHub documents git lfs fetch --all and git lfs push --all as separate steps — and an LFS blob is not a Git object, so the fetch lands in the mirror directory, not the bundle. Archive both, or archive half. And check the bundle, don't assume it — but not with git bundle verify, whose man page promises a nonzero exit "if the bundle file is invalid." It is scoped to "checks on the bundle format itself as well as checking that the prerequisite commits exist and are fully linked in the current repository": header and prerequisites, never the pack. On git 2.53.0 a bundle truncated mid-pack reports itself okay and exits zero; cloning from it dies. So the clone is the proof — unpack the bundle into a throwaway mirror, fsck that, delete it.
#!/usr/bin/env bash
# Usage: ./own-half.sh <clone-url> <workdir>
# Takes the portable half of one repository and proves the copy is complete.
set -euo pipefail
SRC="${1:?usage: own-half.sh <clone-url> <workdir>}"
WORK="${2:?usage: own-half.sh <clone-url> <workdir>}"
NAME="$(basename "${SRC%.git}")"
mkdir -p "$WORK"
cd "$WORK"
# 1. All refs, not just local branches: --mirror maps remote-tracking refs and notes.
git clone --mirror "$SRC" "$NAME.git"
cd "$NAME.git"
# 2. LFS objects are NOT in the mirror, and are NOT in the bundle either.
# A missing git-lfs must be loud: a silent skip would still exit 0 here.
if ! command -v git-lfs >/dev/null 2>&1; then
LFS_OBJECTS='UNKNOWN - git-lfs not installed, LFS NOT CAPTURED'
echo "WARNING: git-lfs not installed; LFS objects NOT captured" >&2
elif ! LFS_OBJECTS=$(git lfs ls-files --all | wc -l | tr -d '[:space:]'); then
LFS_OBJECTS='UNKNOWN - git lfs ls-files failed, LFS NOT CAPTURED'
echo "WARNING: git lfs ls-files failed; LFS objects NOT captured" >&2
elif [ "$LFS_OBJECTS" -gt 0 ]; then
git lfs fetch --all # lands in $NAME.git, not in the bundle
fi
# 3. One file, offline-transferable, holding every ref and reachable commit.
git bundle create "../$NAME.bundle" --all
# 4. Prove the copy by reading it back. git bundle verify does NOT do this:
# it checks the header and the prerequisites, and exits 0 on a bundle whose
# pack is cut short or corrupt. The fsck below walks the MIRROR's objects,
# not the bundle's. Only unpacking the bundle reads the bundle's pack.
git bundle verify "../$NAME.bundle" # prerequisites and format header only
git fsck --full --strict # the mirror's object database
git clone --quiet --mirror "../$NAME.bundle" "../$NAME.restore.git"
git -C "../$NAME.restore.git" fsck --full --strict
# Under set -e a failed restore or fsck exits BEFORE this line, on purpose:
# the broken copy stays for inspection. Remove it by hand before a rerun.
rm -rf "../$NAME.restore.git" # needs room for a second copy
# 5. Record what the copy does and does not contain, beside the copy.
cat > "../$NAME.manifest" <<EOF
repository: $SRC
taken_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)
refs: $(git for-each-ref --format='%(refname)' | wc -l | tr -d '[:space:]')
tags: $(git tag --list | wc -l | tr -d '[:space:]')
head: $(git rev-parse HEAD)
lfs_objects: $LFS_OBJECTS (held in $NAME.git, NOT in the bundle)
not_in_bundle: index, working tree, stash, per-repository config, hooks,
Git LFS objects
not_covered: issues, pull requests, CI definitions, run history, artifacts,
packages, rulesets, webhooks, secrets, runner minutes
EOFWhat Does Not Move, in the Vendors' Own Words
The honest claim is not that forge state cannot move. It can: GitLab's importer and the Forgejo-family migration tool both carry issues, labels, milestones and releases. The claim is narrower: it moves only through a lossy, per-vendor importer whose losses are published — readable this afternoon, not discovered mid-cutover.
GitLab publishes two exclusion lists, one per migration path. For direct transfer, project items are excluded "because they: Might contain sensitive information: CI/CD job logs, CI/CD variables, Container registry images, Deploy keys, Deploy tokens, Encrypted tokens, Job artifacts, Pipeline schedule variables, Pipeline triggers, Webhooks." A second list covers items that "Are not supported" at all: the container, package and infrastructure registries, environments, feature flags, linked issues, merge request approval rules and dependencies, Pages domains, remote mirrors and wiki comments, among others — with a qualifier immediately afterwards, that "Approval rules related to project settings are imported." And the sentence people miss: "users and any personal access tokens they create are excluded from migration."
The file-export path has its own list. "Items that are not exported" includes "CI/CD job traces and artifacts," "Package and container registry images," CI/CD variables, webhooks, encrypted tokens, secure files, "Security policies associated with your project" and "Activity logs for Git-related events (for example, pushing and creating tags)." The record of who pushed what, and when, is not a thing you export.
GitHub is documented with the same candour. GitHub Enterprise Importer states, on its GitHub Enterprise Server and its GitHub.com migration pages alike: "If your repository has more than 10 GB of releases data, releases cannot be migrated. Use the --skip-releases flag to migrate the repository without releases." An importer constraint documented against the --skip-releases argument, not a Git limit — and the first thing to measure if your release assets are large. Going the other way, GitLab's GitHub importer warns that "to make imports as fast as possible, the following items aren't imported from GitHub by default: Markdown attachments from repository comments, release posts, issue descriptions, and pull request descriptions," and that those links "break after you remove the attachments from GitHub." Collaborators narrow too: "Only direct collaborators are imported. Outside collaborators are never imported."
CI Definitions Are a Rewrite, Not a Migration
Pipeline definitions are a re-authoring job, not a data-migration problem. GitLab's own GitHub Actions migration guide settles it: a keyword mapping table, not a converter. It pairs env with variables, jobs with stages, runs-on with tags, steps with script and uses with include — and gives "Not applicable" for both on and run, the keywords carrying a workflow's triggers and its commands.
Read that as an inventory of concepts to re-express, not a translation dictionary. The uses-to-include row is a conceptual equivalence: a marketplace action is a versioned unit of behaviour, an include pulls in YAML. Nothing sourced here supports a claim about what proportion of a pipeline translates cleanly. Assume every workflow is hand-re-authored.
Which suggests the design move that shrinks the question. Keep every forge-specific concern — trigger, runner, checkout, artifact upload — in a thin outer layer and put the build in a repo-local script, which then lives in the zone the protocol carries. That discipline makes a self-hosted Forgejo and Woodpecker stack reachable rather than a rebuild, and is why self-hosted runners under ARC change where builds run, not what they are.
name: build
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# The entire build is one repo-local script. It moves with the repository.
- name: Build, test and package
run: ./ci/build.sh
env:
ARTIFACT_DIR: dist
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/stages:
- build
build:
stage: build
tags:
- linux
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
variables:
ARTIFACT_DIR: dist
script:
- ./ci/build.sh
artifacts:
paths:
- dist/The Metered Surface: Minutes, Artifacts, Packages
Part of what a forge sells is not data. It is consumption, which does not migrate — it stops, and reappears as a line item elsewhere. For private repositories on standard runners, GitHub documents included Actions minutes per month of 2,000 on Free, 3,000 on Pro and Team and 50,000 on Enterprise Cloud, and "at the start of each month, the minutes used by the account are reset to zero." Public repositories on standard runners are not billed against that quota; larger runners are charged for regardless. Storage is coupled to it: "the artifact storage amounts shown are shared with GitHub Packages," so artifacts and published packages compete for one number.
Packages carry the subtler dependency. GitHub documents that "the permissions for a package are either inherited from the repository where the package is hosted, or can be defined for specific users or organizations," and that "some registries only support permissions inherited from a repository." Read that architecturally: for those registries the access model for your artifacts is a function of your repository layout on one forge. It does not travel with the images, which argues for running the registry yourself.
Job logs and artifacts are the purest case. GitLab migrates the pipeline and job records — status, stage, timestamps — on the direct-transfer path, but excludes the logs and the artifacts; the file-export path likewise omits "CI/CD job traces and artifacts." If that output is evidence — provenance for a signed supply chain, or an audit input — it has to land somewhere you control while the builds run, not be extracted afterwards.
Permissions and Audit Evidence: the Least Portable Layer
Access control is where forge data models diverge most. GitHub rulesets are the sharp example: "push rules apply to the entire fork network for a repository, ensuring every entry point to the repository is protected." GitHub documents the mechanics, not the portability. The inference is this article's: a permission shape defined across a graph of repositories rather than on any one of them has no field to import it into elsewhere.
GitLab confirms it from the other direction: imported GitHub branch protection rules "are mapped to one of the following: GitLab branch protection rules, Project-wide GitLab settings," with "Require a pull request before merging" arriving as the "No one" option in "Allowed to push and merge." Reasonable engineering, not equivalence — and that delta is what an auditor asks you to explain.
Commit signatures sit on the boundary. The signature bytes are inside the commit or tag object, so they move with the history. The verification badge is not: it is rendered by whichever forge is reading, against whatever keys it knows. GitLab documents a known issue where "when importing projects that used GitHub auto-merge, the imported project in GitLab can have merge commits labeled unverified if the commit was signed with the GitHub internal GPG key." Nothing was lost; the evidence stopped rendering, because what vouched for it was the tenancy.
Then the layer with no migration path. If your compliance position depends on showing who pushed to a protected branch two years ago, and that record exists only inside the forge you are leaving, it does not come with you. The durable version is the copy already streaming elsewhere — the argument that also governs GitOps reconciliation history across many clusters.
Building the Exit Inventory With gh api
None of this is actionable as prose. As a count it is: one row per repository, split into portable, importable with losses, and must be re-authored. Run it before you have a migration project; it tells you whether you want one. Budget disk: it mirrors every repository in the org.
Three documented traps shape the script. The first silently double-counts: "GitHub's REST API considers every pull request an issue, but not every issue is a pull request. For this reason, 'Issues' endpoints may return both issues and pull requests in the response. You can identify pull requests by the pull_request key." Counts read straight off that endpoint are issues plus pull requests, and nothing surfaces the error until reconciliation. Rulesets repeat the shape: includes_parents defaults to true, so a per-repository query also returns org-level rulesets and an org-wide sweep counts each once per repository. Pass includes_parents=false; count org rulesets once, against orgs/{org}/rulesets.
The second is the primary budget. A personal access token gets 5,000 REST requests per hour; GitHub documents 15,000 for requests made on your behalf by a GitHub App owned by a GitHub Enterprise Cloud organization. The arithmetic: at eight sweeps per repository several hundred repositories approach a personal token's budget, fewer still once a sweep paginates past its first 100 items — read /rate_limit rather than guess.
The third stops a naive sweep within a minute. On top of the hourly budget sit secondary limits: "no more than 100 concurrent requests are allowed. This limit is shared across the REST API and GraphQL API," and a per-endpoint ceiling — the rule is against "too many requests to a single endpoint per minute," where "no more than 900 points per minute are allowed for REST API endpoints." A fan-out across repositories hammers one endpoint pattern, tripping those long before anything hourly runs out. The correct shape is boring on purpose: serial pagination, a pause between sweeps, back-off on rejection.
#!/usr/bin/env bash
# Usage: ./rented-half.sh <owner/repo> <outdir>
# Exports forge metadata to JSONL. Serial by design: the secondary limits cap
# concurrency at 100 requests and REST endpoints at 900 points per minute.
# Token: the hooks sweep is admin-gated. Fine-grained needs Webhooks (read),
# documented at the List endpoint; classic tokens conventionally need
# read:repo_hook or repo. Without it hooks is rejected, not empty.
set -euo pipefail
REPO="${1:?usage: rented-half.sh <owner/repo> <outdir>}"
OUT="${2:?usage: rented-half.sh <owner/repo> <outdir>}"
PAUSE="${PAUSE_BETWEEN_SWEEPS:-20}"
mkdir -p "$OUT"
budget() {
# 5,000/hour on a personal token; 15,000/hour for an app owned by a
# GitHub Enterprise Cloud organization. Read it, do not assume it.
gh api /rate_limit --jq '.resources.core | "\(.remaining)/\(.limit)"'
}
sweep() {
local name="$1" path="$2" attempt
for attempt in 1 2; do
# Write .partial first: a failed sweep must not leave a short .jsonl on
# disk shaped exactly like a complete export.
if gh api --paginate --jq '.[]' "$path" > "$OUT/$name.jsonl.partial"; then
mv "$OUT/$name.jsonl.partial" "$OUT/$name.jsonl"
printf '%-18s %6s objects budget %s\n' \
"$name" "$(wc -l < "$OUT/$name.jsonl")" "$(budget)" >&2
sleep "$PAUSE"
return 0
fi
printf '%s: rejected on attempt %s, backing off 60s\n' "$name" "$attempt" >&2
sleep 60
done
rm -f "$OUT/$name.jsonl.partial" # leave no short file beside the good ones
return 1
}
failed=()
echo "starting budget: $(budget)" >&2
sweep issues_and_pulls "repos/$REPO/issues?state=all&per_page=100" || failed+=(issues_and_pulls)
sweep issue_comments "repos/$REPO/issues/comments?per_page=100" || failed+=(issue_comments)
sweep pulls "repos/$REPO/pulls?state=all&per_page=100" || failed+=(pulls)
sweep labels "repos/$REPO/labels?per_page=100" || failed+=(labels)
sweep milestones "repos/$REPO/milestones?state=all&per_page=100" || failed+=(milestones)
sweep releases "repos/$REPO/releases?per_page=100" || failed+=(releases)
sweep hooks "repos/$REPO/hooks?per_page=100" || failed+=(hooks)
# includes_parents defaults to TRUE. Leave it alone and every org-level ruleset
# is written into every repository's file, so an importer replays one ruleset N
# times. Org rulesets belong to the org: export them once, from orgs/<org>/rulesets.
sweep rulesets "repos/$REPO/rulesets?per_page=100&includes_parents=false" \
|| failed+=(rulesets)
if [ "${#failed[@]}" -gt 0 ]; then
printf 'incomplete sweeps: %s — rerun these alone, not the whole script\n' \
"${failed[*]}" >&2
exit 1
fi
# Only past the failure check: "Issues" endpoints return pull requests too.
# Split on the pull_request key or every count downstream is wrong in the same
# direction. A short file must never produce a plausible-looking split.
jq -c 'select(has("pull_request") | not)' \
< "$OUT/issues_and_pulls.jsonl" > "$OUT/issues_only.jsonl"
printf 'issues %s, pull requests returned as issues %s\n' \
"$(wc -l < "$OUT/issues_only.jsonl")" \
"$(( $(wc -l < "$OUT/issues_and_pulls.jsonl") - $(wc -l < "$OUT/issues_only.jsonl") ))"Aggregate per repository and the inventory writes itself: the Git side counted from a mirror, the forge side through the API. Left columns portable, right columns backlog.
#!/usr/bin/env bash
# Usage: ./exit-inventory.sh <org> <workdir> > exit-inventory.csv
# A rejected API request is never a zero here: the cell reads ERR, the repo is
# named on stderr, and the script exits non-zero. Local tooling that cannot run
# is a DIFFERENT failure: it reads UNKNOWN and does not fail the run.
# Token: hooks is admin-gated (fine-grained Webhooks read; classic
# read:repo_hook or repo). orgs/<org>/rulesets wants fine-grained
# Administration (WRITE) at org level for one stderr line — drop that count
# rather than escalate a read-only token, if that trade is the wrong one.
# Run gh auth setup-git first: the mirror clone authenticates through git's
# credential helper, not the gh token, so a private org prompts or fails.
# Disk: $WORK ends up holding a full mirror clone of every repository.
set -euo pipefail
ORG="${1:?usage: exit-inventory.sh <org> <workdir>}"
WORK="${2:?usage: exit-inventory.sh <org> <workdir>}"
mkdir -p "$WORK"
incomplete=() # API rejections — fatal
tooling=() # local commands that could not run — reported, not fatal
stale=() # mirrors that would not refresh — reported, not fatal
# Probe git-lfs ONCE. An absent binary is not a rejected request and must not
# name every repository in the org as incomplete.
LFS_TOOL=1
if ! command -v git-lfs >/dev/null 2>&1; then
LFS_TOOL=0
echo "WARNING: git-lfs not installed; lfs_objects reads UNKNOWN in every row" >&2
fi
# Back off on rejection, as the sweep script does, then give up loudly.
api() {
local attempt out
for attempt in 1 2 3; do
if out=$(gh api "$@"); then
printf '%s' "$out"
return 0
fi
if [ "$attempt" -lt 3 ]; then
sleep $((attempt * 30)) # no dead wait after the final attempt
fi
done
return 1
}
count() {
local out
out=$(api --paginate --jq '.[].id' "$1") || return 1
printf '%s\n' "$out" | grep -c . || true
}
repos=$(api --paginate --jq '.[].full_name' "orgs/$ORG/repos?per_page=100") || {
echo "FATAL: could not list repositories for $ORG" >&2
exit 1
}
# Org-level rulesets exist once, not once per repository. Count them here, and
# pass includes_parents=false below so they are not re-counted in every row.
if org_rulesets=$(count "orgs/$ORG/rulesets?per_page=100"); then
printf 'org-level rulesets (counted once, not per repository): %s\n' \
"$org_rulesets" >&2
else
echo "WARNING: could not count org-level rulesets for $ORG" >&2
fi
printf 'repo,size_kb,refs,tags,lfs_objects,issues,pulls,releases,rulesets,hooks,workflows\n'
while read -r repo; do
[ -n "$repo" ] || continue
name="${repo##*/}"
bad=0
# Re-running against the same workdir is the designed path, so REFRESH the
# mirror rather than reuse it. Skipping this reports last run's size, refs
# and tags beside today's live API counts: a wrong row, not an old one.
# (git remote update rejects --quiet; a mirror's refspec is +refs/*:refs/*,
# so a pruning fetch of origin is the same update.)
if [ -d "$WORK/$name.git" ]; then
git -C "$WORK/$name.git" fetch --prune --quiet origin || {
echo "WARNING: mirror refresh failed for $repo; Git columns are stale" >&2
stale+=("$repo")
}
else
git clone --quiet --mirror "https://github.com/$repo.git" "$WORK/$name.git"
fi
# Owned side: counted from the object database, no API, no rate limit.
# tr strips the leading spaces BSD wc pads counts with; GNU wc does not.
refs=$(git -C "$WORK/$name.git" for-each-ref --format='%(refname)' |
wc -l | tr -d '[:space:]')
tags=$(git -C "$WORK/$name.git" tag --list | wc -l | tr -d '[:space:]')
size=$(du -sk "$WORK/$name.git" | cut -f1 | tr -d '[:space:]')
if [ "$LFS_TOOL" -eq 0 ]; then
lfs=UNKNOWN
elif ! lfs=$(git -C "$WORK/$name.git" lfs ls-files --all |
wc -l | tr -d '[:space:]'); then
lfs=UNKNOWN
tooling+=("$repo")
fi
# Rented side: split issues from pull requests on the documented key.
if kinds=$(api --paginate "repos/$repo/issues?state=all&per_page=100" \
--jq '.[] | has("pull_request")'); then
issues=$(grep -c '^false$' <<<"$kinds" || true)
pulls=$(grep -c '^true$' <<<"$kinds" || true)
else
issues=ERR
pulls=ERR
bad=1
fi
releases=$(count "repos/$repo/releases?per_page=100") || { releases=ERR; bad=1; }
# includes_parents defaults to TRUE: without this, three org rulesets on a
# 200-repo org emit 600 rows. Per-repository rulesets only.
rulesets=$(count "repos/$repo/rulesets?per_page=100&includes_parents=false") ||
{ rulesets=ERR; bad=1; }
hooks=$(count "repos/$repo/hooks?per_page=100") || { hooks=ERR; bad=1; }
workflows=$(api "repos/$repo/actions/workflows" --jq '.total_count') ||
{ workflows=ERR; bad=1; }
[ "$bad" -eq 0 ] || incomplete+=("$repo")
printf '%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n' \
"$repo" "$size" "$refs" "$tags" "$lfs" "$issues" "$pulls" \
"$releases" "$rulesets" "$hooks" "$workflows"
sleep 5
done <<<"$repos"
if [ "${#tooling[@]}" -gt 0 ]; then
printf 'LFS NOT COUNTED - local git lfs could not run, nothing was rejected: %s\n' \
"${tooling[*]}" >&2
fi
if [ "${#stale[@]}" -gt 0 ]; then
printf 'STALE GIT COLUMNS - mirror refresh failed, size/refs/tags predate this run: %s\n' \
"${stale[*]}" >&2
fi
if [ "${#incomplete[@]}" -gt 0 ]; then
printf 'INCOMPLETE ROWS - an API request was rejected, those counts are not zeroes: %s\n' \
"${incomplete[*]}" >&2
exit 1
fiPricing the Exit
The inventory gives units, not a number: converting them to hours is a property of your team, not the forge. Any total arriving without your own blended rate is a guess wearing a decimal point.
- Pipelines to re-author — one per workflow file, weighted by how much logic sits in the YAML rather than a script.
- Protected branches, rulesets and CODEOWNERS — redesigned rather than mapped, re-reviewed by whoever signs off access.
- Registries — every package and image re-published, because both GitLab paths exclude registry contents.
- Webhooks, deploy keys and tokens — every forge credential, all on the exclusion lists.
- Repositories over the documented ceiling — release assets near 10 GB, against GitHub Enterprise Importer's documented limit.
- Metadata that will not import cleanly — the delta between your export counts and what the destination documents it accepts.
Published seat prices belong here, but only for what they price: the recurring cost of staying. Read from the pricing pages on 28 September 2026, GitHub lists Team at $4 USD per user/month and Enterprise at "Starting at $21 USD per user/month" — each figure printed twice at the same value, once plain and once footnoted "for the first 12 months." GitLab lists Premium at $29 per user/month billed annually, a seat price rather than a platform total: the same page carries $12 of included GitLab Credits per user/month against Premium and $24 against Ultimate, both footnoted as a limited-time promotional offer, so AI usage past the allowance meters on top of the seat at "the standard rate of $1/credit." Ultimate itself reads "Get custom pricing."
Which makes the question tractable: a recurring public cost of staying against a one-off countable cost of leaving, shrinking each time you move a piece from the outer zone to the inner — the generic exit-cost model applied to a system whose vendors publish their limits.
Designing So the Exit Stays Cheap
Run a continuous mirror, and know what it covers. Forgejo documents push mirrors to GitHub, GitLab and Bitbucket, so a self-hosted forge can hold the authoritative copy while the commercial one stays a replica. The caveat: a mirror carries Git data only. Forgejo's migration imports releases and their assets; the request to have that run continuously was closed, its own author noting the feature "does already exist, but is mutually exclusive with a code mirror." A live code mirror therefore carries history alone; pair every one with a scheduled release export against the API. Even mirroring touches the permission model: a push mirror to GitHub needs Contents permission, plus Workflows if the repository holds anything under .github/workflows.
Keep CI logic in repo-local scripts. Every line of build behaviour inside the repository moves by protocol; every line in the workflow YAML is re-authored. It turns the largest re-authoring bucket into the smallest, at no cost on the next pipeline you touch.
Publish to a registry you could re-point. Registry contents are excluded from both GitLab migration paths and nothing carries them for you. A registry you operate, or one addressed through a name you control, turns an image migration into a DNS change. The logic extends to developer environments: anything living only inside the forge's runtime is something you rebuild.
Export metadata on a schedule, not on an incident. The export script above belongs in a cron job writing to storage you control. A few thousand API calls a week turns the outer zone from a hostage into a dated copy, so leaving argues over a backlog you can count, not over whether migration is possible.
The long game is the simplest part. Git's object format has already outlived SourceForge's dominance, Google Code, Gitorious and Bitbucket's Mercurial support; on that record, a team still running in 2040 that never changed forge would be the exception. Not a forecast about which vendor fails — an observation about how tenancies end. The protocol is the durable layer; the tenancy is a lease renewed monthly, on somebody else's schedule.
§FAQ/Common questions
Frequently asked
What is the difference between Git, GitHub and GitLab?
Git is a distributed version control system — a content-addressed object format plus a wire protocol, implemented by many programs and owned by nobody. GitHub and GitLab are hosted platforms built over that protocol, each adding its own data model: issues, pull or merge requests, CI/CD definitions and run history, package and container registries, permission systems, webhooks. The practical consequence is an ownership boundary, not a feature comparison. Anything in the Git layer moves to any other implementation losslessly with a mirror clone or a bundle, and is verifiable against its own hashes. Anything in the platform layer lives in one vendor's schema and moves only through that vendor's importer, which both GitHub and GitLab document with explicit exclusion lists. Bitbucket sits in the same position, a third tenancy over the same protocol. So the choice is not about which has the better runner or AI assistant; it is a decision about how much of your delivery system you are willing to hold in a shape only one vendor can read.
Does a git clone --mirror back up everything in my repository?
It backs up the Git layer completely and nothing else. Git documents that --mirror "not only maps local branches of the source to local branches of the target, it maps all refs (including remote-tracking branches, notes etc.)," so a mirror is a full copy of the history, branches, tags and notes. Two gaps matter. First, Git LFS objects are not included — GitHub's own duplicate-a-repository procedure lists git lfs fetch --all and git lfs push --all as separate steps. Second, and much larger: none of the forge data model is in there: no issues, no pull requests or review threads, no CI definitions or run history, no packages, no rulesets, no webhooks. A mirror plus a git bundle is the right backup of what Git owns, and Git's bundle documentation is explicit that even that excludes the index, the working tree, the stash, per-repository configuration and hooks. Treat the mirror as one of two backups you need, not as the backup.
What does not transfer when migrating from GitHub to GitLab, or the other way?
Both vendors publish the answer, which is the useful part. As documented on 28 September 2026, GitLab's direct-transfer path excludes CI/CD job logs, CI/CD variables, container registry images, deploy keys and tokens, encrypted tokens, job artifacts, pipeline schedule variables, pipeline triggers and webhooks on sensitivity grounds, and lists the container registry, package registry, environments, feature flags, merge request approval rules and dependencies, remote mirrors and wiki comments, among others, as not supported — though approval rules related to project settings are imported. Users themselves, and any personal access tokens they created, are excluded. GitLab's file-export path additionally omits CI/CD job traces and artifacts, security policies and activity logs for Git-related events such as pushes and tag creation. Going the other way, GitHub Enterprise Importer documents that a repository with more than 10 GB of releases data cannot have its releases migrated at all through GitHub Enterprise Importer, with --skip-releases as the workaround. These lists are versioned, so check the documentation for the version you are running.
How do I export all my GitHub issues before leaving?
Use gh api with --paginate, and account for three documented behaviours or your numbers will be wrong. First, GitHub's REST API "considers every pull request an issue," so the Issues endpoints return both and you must split them on the pull_request key — otherwise every issue count you report is inflated by your pull requests. Second, pace the sweep against the primary rate limit: 5,000 requests per hour on a personal access token, or 15,000 for requests made by a GitHub App owned by a GitHub Enterprise Cloud organization. Third, the one that actually breaks naive scripts: respect the secondary limits — GitHub allows no more than 100 concurrent requests across REST and GraphQL, and, per endpoint per minute, no more than 900 points for REST endpoints. A parallel fan-out across repositories hits those within a minute. Paginate serially, pause between endpoint sweeps, read /rate_limit rather than assume a budget, and back off on rejection rather than retry immediately.
Is self-hosting a forge cheaper than GitHub or GitLab?
That is the wrong comparison to lead with, because the two costs have different shapes. Seat pricing is recurring and public: read on 28 September 2026, GitHub lists Team at $4 USD per user/month and Enterprise at "Starting at $21 USD per user/month," each shown beside a footnoted first-twelve-months figure, while GitLab lists Premium at $29 per user/month billed annually with $12 of promotional included credits alongside it, and Ultimate as quote-only. Self-hosting replaces that with hardware, operational attention and an upgrade cadence you own. The more decision-relevant number is the one nobody publishes: the cost of re-authoring the part of your delivery system that only exists in one vendor's data model. That cost is countable — pipelines, rulesets, registries, webhooks — and it shrinks every time you move a piece into the layer the protocol carries. Run the inventory first. A team whose build logic already lives in repo-local scripts and whose images already go to a registry it controls is looking at a very different number from one whose pipelines are a thousand lines of vendor-specific YAML.
Further reading
- Terraform vs CloudFormation: Who Holds the Record
- OpenShift vs Kubernetes: Pricing the Exit to Vanilla
- Forgejo, Woodpecker and Zot: CI Off GitHub Actions
- Vendor Lock-In in the Cloud: Pricing Your Exit as a Number
- GitHub Self-Hosted Runners on Kubernetes with ARC
- Own the Registry: Harbor and Zot for Air-Gapped Images
- Supply Chain Security: SBOM, Sigstore and Admission Control
- DevPod and Coder: Sovereign Dev Environments on Kubernetes
- Multi-Cluster GitOps at 100+ Clusters: Argo CD's Limits vs Flux
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.