Created
March 30, 2026 17:13
-
-
Save mrcgrtz/e262c60346f72e5615b465ca4eb69aae to your computer and use it in GitHub Desktop.
Migrate from Bitbucket to GitHub
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # ============================================================================= | |
| # migrate-bitbucket-to-github.sh | |
| # | |
| # Batch-migrates all private Bitbucket repositories to GitHub. | |
| # - Repos are created as private on GitHub | |
| # - The default branch is renamed from `master` to `main` (if applicable) | |
| # - Local clones are cleaned up immediately after each push | |
| # - A temporary working directory is used and removed on exit | |
| # | |
| # Prerequisites: | |
| # - `git`, `curl`, and `jq` must be installed | |
| # - Bitbucket API Token with "Repository: Read" scope | |
| # (create at bitbucket.org → Personal Bitbucket settings → API tokens) | |
| # - Bitbucket account email address | |
| # (find at bitbucket.org → Personal Bitbucket settings → Email aliases) | |
| # - GitHub Personal Access Token (classic) with "repo" scope | |
| # (create at github.com → Settings → Developer settings → Personal access tokens) | |
| # | |
| # Usage: | |
| # chmod +x migrate-bitbucket-to-github.sh | |
| # ./migrate-bitbucket-to-github.sh | |
| # ============================================================================= | |
| # === Configuration === | |
| BITBUCKET_USER="" # Your Bitbucket username (not email) | |
| BITBUCKET_EMAIL="" # Your Atlassian account email (for REST API auth) | |
| BITBUCKET_API_TOKEN="" # Your Bitbucket API token | |
| GITHUB_USER="" # Your GitHub username (not email) | |
| GITHUB_TOKEN="" # GitHub Personal Access Token (classic) with "repo" scope | |
| # === Temporary working directory === | |
| # All bare clones are created here and cleaned up after each repo. | |
| # The trap ensures the directory is removed on exit, even on error. | |
| WORKDIR=$(mktemp -d) | |
| cd "$WORKDIR" | |
| trap "rm -rf '$WORKDIR'" EXIT | |
| # === Fetch all Bitbucket repos === | |
| echo "Fetching all Bitbucket repos for user: $BITBUCKET_USER ..." | |
| # REST API authentication uses email + API token (Basic Auth). | |
| # Note: If you have more than 100 repos, pagination needs to be implemented. | |
| repos=$(curl -s -u "$BITBUCKET_EMAIL:$BITBUCKET_API_TOKEN" \ | |
| "https://api.bitbucket.org/2.0/repositories/$BITBUCKET_USER?pagelen=100" \ | |
| | jq -r '.values[].name') | |
| echo "Found repos:" | |
| echo "$repos" | |
| # === For each repo: create on GitHub, mirror, rename branch, push === | |
| for repo in $repos; do | |
| echo "--------------------------------------------" | |
| echo "Processing repo: $repo" | |
| # Create the repo on GitHub as private. | |
| # HTTP 201 = created, 422 = already exists (both are fine). | |
| response=$(curl -s -o /dev/null -w "%{http_code}" -X POST "https://api.github.com/user/repos" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $GITHUB_TOKEN" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| -d "{\"name\":\"$repo\",\"private\":true}") | |
| if [[ "$response" == "201" ]]; then | |
| echo "Created new repo on GitHub: $repo" | |
| elif [[ "$response" == "422" ]]; then | |
| echo "Repo already exists on GitHub: $repo" | |
| else | |
| echo "Failed to create repo on GitHub: HTTP $response — skipping." | |
| continue | |
| fi | |
| # Clone a bare mirror from Bitbucket. | |
| # Git authentication uses the static username x-bitbucket-api-token-auth + API token. | |
| echo "Cloning mirror from Bitbucket..." | |
| git clone --mirror \ | |
| "https://x-bitbucket-api-token-auth:$BITBUCKET_API_TOKEN@bitbucket.org/$BITBUCKET_USER/$repo.git" \ | |
| || { echo "Clone failed, skipping $repo."; continue; } | |
| cd "$repo.git" | |
| # Rename master to main if master exists. | |
| # git symbolic-ref updates HEAD so GitHub picks up main as the default branch. | |
| if git show-ref --verify --quiet refs/heads/master; then | |
| echo "Renaming master → main..." | |
| git branch -m master main | |
| git symbolic-ref HEAD refs/heads/main | |
| echo "Renamed master to main." | |
| else | |
| echo "No master branch found, skipping rename." | |
| fi | |
| # Push all refs to GitHub. | |
| echo "Pushing mirror to GitHub..." | |
| git remote set-url origin \ | |
| "https://$GITHUB_USER:$GITHUB_TOKEN@github.com/$GITHUB_USER/$repo.git" | |
| git push --mirror | |
| cd .. | |
| # Remove the local bare clone immediately after pushing. | |
| echo "Cleaning up local clone..." | |
| rm -rf "$repo.git" | |
| echo "Done: $repo" | |
| done | |
| echo "============================================" | |
| echo "All repos migrated!" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment