A practical guide to bulk migrating your GitHub repositories to Bitbucket using a simple bash script
Suprasanna Ojha
So I had this situation where I wanted to have all my GitHub repositories backed up on Bitbucket. You know, the whole "don't put all your eggs in one basket" thing. Seemed simple enough, right?
I went online looking for a guide and... every single tutorial I found was about migrating one repository at a time. Click this, click that, import, wait, repeat. With over 50 repos, that was going to take forever. And honestly, who has time for that?
After digging around for a while, I couldn't find anything that did bulk migration properly. So I ended up writing my own script. And since I went through all that trouble, I figured I'd share it here in case someone else runs into the same problem.
The idea is simple:
Let's break it down piece by piece.
You'll need a few things set up first. Don't worry, most of these are probably already on your machine if you're a developer.
Here's everything the script needs to run:
You definitely need git installed. If you're reading this, you probably have it already. But just in case:
Mac:
# Usually comes with Xcode Command Line Tools
xcode-select --install
# Or via Homebrew
brew install git
Linux (Ubuntu/Debian):
sudo apt update && sudo apt install git
Windows:
Download from git-scm.com or use winget install Git.Git
To check if you have it: git --version
This is for making API calls to GitHub and Bitbucket. It comes pre-installed on most systems.
Mac: Already installed
Linux (Ubuntu/Debian):
sudo apt install curl
To check: curl --version
This is a command-line JSON processor. We use it to parse the responses from the APIs. This one you might not have.
Mac:
brew install jq
Linux (Ubuntu/Debian):
sudo apt install jq
Windows:
winget install jqlang.jq
You can also download it directly from jqlang.github.io/jq/download
To check: jq --version
We only use Python for one thing - URL encoding special characters in the API token. Nothing fancy.
Mac:
# Usually pre-installed, but if not:
brew install python3
Linux (Ubuntu/Debian):
sudo apt install python3
Windows:
Download from python.org or use winget install Python.Python.3.12
To check: python3 --version
Before moving on, run these commands to make sure everything's installed:
git --version
curl --version
jq --version
python3 --version
If any of these fail, go back and install the missing tool.
You need a token to access your GitHub repos through the API.
repo scope (full control of private repositories)Here's something that tripped me up initially - Bitbucket used to have something called "App Passwords" but they've replaced that with API tokens now.
To get your API token:
You'll also need to know your Bitbucket workspace name. That's the part that appears in your Bitbucket URLs like bitbucket.org/YOUR_WORKSPACE/repo-name.
The script uses environment variables for all the credentials. This keeps sensitive stuff out of the script itself.
Before running anything, you'll need to set these:
export GITHUB_USER="your_github_username"
export GITHUB_TOKEN="your_github_personal_access_token"
export BITBUCKET_WORKSPACE="your_bitbucket_workspace"
export BITBUCKET_EMAIL="your_atlassian_email"
export BITBUCKET_API_TOKEN="your_bitbucket_api_token"
The script checks if all of these are set and will stop with an error if any are missing:
for var in GITHUB_USER GITHUB_TOKEN BITBUCKET_WORKSPACE BITBUCKET_EMAIL BITBUCKET_API_TOKEN; do
if [[ -z "${!var}" ]]; then
echo "Error: $var environment variable is not set"
exit 1
fi
done
Nothing worse than running a script halfway through and realizing you forgot to set something.
Before doing anything else, the script makes sure your Bitbucket credentials actually work:
echo "Testing Bitbucket authentication..."
BB_TEST=$(curl -s -u "$BITBUCKET_EMAIL:$BITBUCKET_API_TOKEN" \
"https://api.bitbucket.org/2.0/user")
if echo "$BB_TEST" | jq -e '.username' >/dev/null 2>&1; then
echo "Bitbucket auth OK: $(echo "$BB_TEST" | jq -r '.username')"
else
echo "Bitbucket authentication failed:"
echo "$BB_TEST"
exit 1
fi
This calls the Bitbucket API with your credentials and checks if it returns a valid username. If it doesn't, you'll know right away instead of finding out 30 repos into the migration.
Now we grab the list of all your repos from GitHub:
repos=$(curl -s -u "$GITHUB_USER:$GITHUB_TOKEN" \
"https://api.github.com/user/repos?per_page=200&affiliation=owner&visibility=all" \
| jq -r '.[] | select(.fork == false) | .name')
A few things happening here:
per_page=200 gets up to 200 repos in one call (if you have more than 200, you'd need pagination but that's a rare case)affiliation=owner means only repos you own, not ones you're just a collaborator onvisibility=all includes both public and private reposjq command filters out forked repos (since you probably don't need to back those up) and extracts just the repo namesNow we loop through each repo and do the actual migration:
for repo in $repos
do
echo "Processing $repo"
cd "$WORK_DIR"
# Skip if already migrated
if grep -q "^${repo}$" "$PROGRESS_FILE" 2>/dev/null; then
echo " Skipping $repo (already migrated)"
continue
fi
See that progress file check? This is really useful. If your internet drops or something fails halfway through, you can just run the script again and it'll pick up where it left off instead of starting over.
# Convert repo name to lowercase for Bitbucket
bb_repo=$(echo "$repo" | tr '[:upper:]' '[:lower:]')
# Create repo in Bitbucket
BB_RESPONSE=$(curl -s -X POST \
-u "$BITBUCKET_EMAIL:$BITBUCKET_API_TOKEN" \
-H "Content-Type: application/json" \
"https://api.bitbucket.org/2.0/repositories/$BITBUCKET_WORKSPACE/$bb_repo" \
-d '{"scm":"git","is_private":true}')
One thing I learned: Bitbucket requires repository slugs (the URL-friendly name) to be lowercase. So if you have a repo called MyAwesomeProject on GitHub, it becomes myawesomeproject on Bitbucket. The script handles this conversion automatically.
Also, all repos are created as private by default. Change is_private to false if you want them public.
Here's where the magic happens:
if git clone --mirror "https://$GITHUB_USER:$GITHUB_TOKEN@github.com/$GITHUB_USER/$repo.git"; then
cd "$repo.git"
git push --mirror "https://x-bitbucket-api-token-auth:${ENCODED_BB_TOKEN}@bitbucket.org/$BITBUCKET_WORKSPACE/$bb_repo.git"
cd "$WORK_DIR"
rm -rf "$repo.git"
echo " Mirror push complete"
# Mark as complete
echo "$repo" >> "$PROGRESS_FILE"
fi
The --mirror flag is key here. A mirror clone gets everything - all branches, all tags, all refs. It's a complete copy of the repository, not just the main branch.
Then we push with --mirror to Bitbucket, which pushes everything we just cloned.
One weird thing about Bitbucket authentication in git URLs - you have to use x-bitbucket-api-token-auth as the username when using API tokens. Not your email, not your username. Just that exact string. Took me a while to figure that one out.
If your API token has special characters in it (and it probably does), you need to URL encode it before putting it in a git URL:
urlencode() {
python3 -c "import urllib.parse; print(urllib.parse.quote('$1', safe=''))"
}
ENCODED_BB_TOKEN=$(urlencode "$BITBUCKET_API_TOKEN")
This prevents characters like @ or & in your token from breaking the URL.
Here's the full script all together. Save this as something like migrate-to-bitbucket.sh:
#!/bin/bash
set -e
# Validate required environment variables
for var in GITHUB_USER GITHUB_TOKEN BITBUCKET_WORKSPACE BITBUCKET_EMAIL BITBUCKET_API_TOKEN; do
if [[ -z "${!var}" ]]; then
echo "Error: $var environment variable is not set"
exit 1
fi
done
WORK_DIR="$(pwd)"
PROGRESS_FILE="$WORK_DIR/migrated_repos.txt"
touch "$PROGRESS_FILE"
# URL-encode function
urlencode() {
python3 -c "import urllib.parse; print(urllib.parse.quote('$1', safe=''))"
}
ENCODED_BB_TOKEN=$(urlencode "$BITBUCKET_API_TOKEN")
# Test Bitbucket authentication
echo "Testing Bitbucket authentication..."
BB_TEST=$(curl -s -u "$BITBUCKET_EMAIL:$BITBUCKET_API_TOKEN" \
"https://api.bitbucket.org/2.0/user")
if echo "$BB_TEST" | jq -e '.username' >/dev/null 2>&1; then
echo "Bitbucket auth OK: $(echo "$BB_TEST" | jq -r '.username')"
else
echo "Bitbucket authentication failed:"
echo "$BB_TEST"
exit 1
fi
# Fetch GitHub repositories
echo "Fetching GitHub repositories..."
repos=$(curl -s -u "$GITHUB_USER:$GITHUB_TOKEN" \
"https://api.github.com/user/repos?per_page=200&affiliation=owner&visibility=all" \
| jq -r '.[] | select(.fork == false) | .name')
for repo in $repos
do
echo "Processing $repo"
cd "$WORK_DIR"
# Skip if already migrated
if grep -q "^${repo}$" "$PROGRESS_FILE" 2>/dev/null; then
echo " Skipping $repo (already migrated)"
continue
fi
bb_repo=$(echo "$repo" | tr '[:upper:]' '[:lower:]')
rm -rf "$repo.git"
# Create Bitbucket repo
echo " Creating Bitbucket repo..."
curl -s -X POST \
-u "$BITBUCKET_EMAIL:$BITBUCKET_API_TOKEN" \
-H "Content-Type: application/json" \
"https://api.bitbucket.org/2.0/repositories/$BITBUCKET_WORKSPACE/$bb_repo" \
-d '{"scm":"git","is_private":true}' > /dev/null
# Mirror clone and push
if git clone --mirror "https://$GITHUB_USER:$GITHUB_TOKEN@github.com/$GITHUB_USER/$repo.git"; then
cd "$repo.git"
git push --mirror "https://x-bitbucket-api-token-auth:${ENCODED_BB_TOKEN}@bitbucket.org/$BITBUCKET_WORKSPACE/$bb_repo.git"
cd "$WORK_DIR"
rm -rf "$repo.git"
echo "$repo" >> "$PROGRESS_FILE"
echo " Done"
else
echo " Failed to clone $repo, skipping..."
fi
done
echo "Migration complete!"
chmod +x migrate-to-bitbucket.sh
export GITHUB_USER="yourusername"
export GITHUB_TOKEN="ghp_xxxxxxxxxxxx"
export BITBUCKET_WORKSPACE="yourworkspace"
export BITBUCKET_EMAIL="you@email.com"
export BITBUCKET_API_TOKEN="your_api_token"
./migrate-to-bitbucket.sh
A few gotchas that might save you some time:
x-bitbucket-api-token-auth as the username. Not your actual username or email.git clone - use git clone --mirror to get everything including all branches and tags.That's basically it. What would have taken me hours of clicking through web interfaces took about 20 minutes to run. And now I have all my repos backed up on Bitbucket without having to think about it.
If you run into any issues or have questions, feel free to reach out. And if you end up using this script, I'd love to hear about it!