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
What moves with the objects and what does notA mirror push carries every commit, branch and tag exactly as they are. Protection rules, pipeline secrets, webhooks, deploy keys and issue history live in the forge's own database and have to be recreated. References to the old URL live in other repositories entirely.Moves automaticallycommits and treesbranches and tagssignaturesnotesMust be recreatedprotection rulesrequired checkssecrets and variableswebhooks, deploy keysLives elsewheresubmodule URLspipeline definitionsdocumentation linkslocal remotesthe left column is the part that takes five minutes

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 WARNINGgit push --mirror makes 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
The cutover, and why the old host stays upThe mirror push and configuration rebuild happen while the source is still writable, so nothing is at risk. The source is then made read-only rather than deleted, which turns every missed reference into a clear failure instead of a silent one.Mirror pushobjects identicalT+0Rebuild configprotection, secrets, hooksT+30mSource read-onlypushes fail loudlyT+1hReferences updatedsubmodules, pipelinesT+1dDecommissionafter one release cycleT+30da read-only source is what makes a missed reference a five-minute fix

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
Archiving the source against deleting itAn archived repository still answers clones and reads, so a missed reference produces a clear failure and a quick fix. A deleted one produces a broken link with no way to recover what it pointed at, and the cost lands on whoever finds it months later.Delete the sourceArchive it read-onlymissed referencebroken, unrecoverablestill resolvesaccidental pushesimpossiblerefused loudlyaudit and history accessgonepreservedongoing costnonenegligiblethe only argument for deleting is tidiness, which is not worth what it costs

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.