Moving a repository between hosting providers Jump to heading
Of the four migration shapes, this is the only one that leaves commit ids untouched — the objects are copied byte for byte and every existing clone stays valid. That makes it feel like the easy one, which is exactly why it goes wrong: the objects are the smaller half. Branch protection, required checks, pipeline secrets, webhooks, deploy keys, issue links and every configuration file naming the old URL live outside the object database and move only if you move them. This recipe covers both halves, within repository migration and consolidation.
When to use this approach Jump to heading
- You are changing forge providers, or moving between organisations.
- History must be preserved exactly, including signatures.
- Existing clones should keep working after a remote URL change.
- You have administrative access to both the source and the destination.
- If you also want to rewrite history, do the move first and the rewrite second — combining them makes the result impossible to verify.
Step 1 — Inventory what lives outside the objects Jump to heading
Do this before touching anything. The list is always longer than expected.
# Refs, which do move with a mirror push
git ls-remote https://old.example.com/acme/app.git | wc -l # Protection rules, required checks and settings, which do not
gh api repos/acme/app/rulesets --jq '.[] | {name, target}'
gh api repos/acme/app/branches/main/protection --jq 'keys'
gh api repos/acme/app/hooks --jq '.[] | {id, config: .config.url, events}'
gh api repos/acme/app/keys --jq '.[] | {title, read_only}'
gh api repos/acme/app/actions/secrets --jq '.secrets[].name' # And every reference to the old URL anywhere in the codebase
grep -rn 'old\.example\.com' --exclude-dir=.git . | head -20 Step 2 — Mirror the objects Jump to heading
git clone --mirror https://old.example.com/acme/app.git app.git
cd app.git
git remote set-url --push origin [email protected]:acme/app.git
git push --mirror # Verification: both sides should list identical refs and object ids
git ls-remote https://old.example.com/acme/app.git | sort > /tmp/old-refs.txt
git ls-remote [email protected]:acme/app.git | sort > /tmp/new-refs.txt
diff /tmp/old-refs.txt /tmp/new-refs.txt && echo "refs identical" SAFETY WARNING —
git push --mirrormakes the destination match the source exactly, which includes deleting refs the destination has and the source does not. Run it against an empty destination repository. Pushing a mirror over a repository that already has work destroys that work with no confirmation and no server-side reflog on most providers.
Step 3 — Recreate the forge-side configuration Jump to heading
Export what you can, and rebuild the rest deliberately rather than from memory.
# Protection: read the source, write the destination
gh api repos/acme/app/rulesets --jq '.[] | select(.target=="branch")' > /tmp/rulesets.json
jq -c '.[]' /tmp/rulesets.json 2>/dev/null | while read -r r; do
echo "$r" | gh api -X POST repos/acme-new/app/rulesets --input - || echo "review this one by hand"
done # Secrets cannot be read — they must be re-entered from their source of truth
gh api repos/acme/app/actions/secrets --jq '.secrets[].name' | while read -r name; do
echo "re-enter: $name"
done # Verification: compare the settings that matter
for repo in acme/app acme-new/app; do
printf '%s: ' "$repo"
gh api "repos/$repo" --jq '{private, default_branch, delete_branch_on_merge}'
done Secrets are the item most often forgotten, because nothing fails until the first pipeline run after the cutover. Reading them out of the forge is deliberately impossible, so the source of truth has to be somewhere else — which is itself worth checking.
Step 4 — Update everything that names the old URL Jump to heading
# Submodule pointers in other repositories
grep -rn 'old\.example\.com' --include='.gitmodules' ~/work | head
git submodule sync --recursive # Local clones: one command per developer
git remote set-url origin [email protected]:acme/app.git
git remote -v # Verification: nothing in the organisation still points at the old host
gh search code 'old.example.com' --owner acme --json path,repository \
--jq '.[] | "\(.repository.nameWithOwner) \(.path)"' | head -20 Step 5 — Make the old location fail loudly Jump to heading
The worst outcome is a source that still accepts pushes, because work then accumulates in a repository nobody is watching.
# Archive the source, which makes it read-only on most providers
gh api -X PATCH repos/acme/app -F archived=true # Verification: a push to the old location must be refused
git push https://old.example.com/acme/app.git HEAD:refs/heads/probe 2>&1 | grep -qi 'archiv\|read-only\|denied' \
&& echo "old location is read-only" # And a final object-level comparison, for the record
git --git-dir=app.git rev-list --count --all
git ls-remote [email protected]:acme/app.git | wc -l Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Do signed commits still verify after the move? Jump to heading
Yes — the objects are unchanged, so the signatures are still valid. What may need attention is the trust configuration on the new host: allowed-signers files, verified email addresses and organisation key settings are forge-side configuration and fall into the “must be recreated” column. The details are in enforcing signed commits with branch protection.
What happens to issues and pull requests? Jump to heading
They do not move with the objects, because they are not in them. Most providers offer an importer that recreates them with new numbers, which breaks every existing reference. Keeping the source archived and readable is usually a better answer than a lossy import.
Can we run both hosts in parallel for a while? Jump to heading
Only with one of them read-only. Two writable copies of a repository diverge within days, and reconciling them is a merge nobody planned for. If you need a period of overlap, mirror one direction automatically and keep the other refusing pushes.
Related Jump to heading
- Repository Migration & Consolidation — the parent topic and the four shapes.
- Archiving a Repository Without Losing History — what read-only should actually mean.
- Designing Branch Protection Rulesets for an Org — rebuilding protection deliberately rather than by memory.