DEV Community

Ahmed Moussa
Ahmed Moussa

Posted on

Migrating 60+ Git Branches from Azure DevOps to GitHub Enterprise: A Practical Guide

AI Used Notice

Gemini AI was used to rewrite the article more engagingly and generate the image.

Migrating 60+ Git Branches from Azure DevOps to GitHub Enterprise: A Practical Guide

The Challenge

Picture this: Your devops team just completed a repository migration from Azure DevOps (ADO) to GitHub Enterprise. Everything seems fine until you realize that some team members kept working on the old ADO repository. Now you have 62 branches that need to be manually migrated to the new GitHub repo.

Doing this manually would mean:

  1. Checking out each branch locally
  2. Pushing it to the new remote
  3. Repeating 62 times
  4. Dealing with merge conflicts and authentication issues
  5. Probably making mistakes and losing track

There has to be a better way! 🤔

The Solution: Automation

Let's build a bash script that automates this entire process. I'll walk you through the solution, the challenges we faced, and how to handle them.

Understanding the Git Setup

When migrating repositories, you typically have two remotes:

git remote -v
# ado      https://dev.azure.com/org/project/_git/repo (fetch)
# ado      https://dev.azure.com/org/project/_git/repo (push)
# github   https://github.company.com/org/repo.git (fetch)
# github   https://github.company.com/org/repo.git (push)
Enter fullscreen mode Exit fullscreen mode

Our goal is to:

  1. Fetch all branches from ADO
  2. Check if they exist on GitHub
  3. Push them to GitHub (updating if necessary)
  4. Skip branches that are already up-to-date
  5. Report the results

The Basic Migration Script

Here's the core concept:

#!/bin/bash

ADO_REMOTE="ado"
GITHUB_REMOTE="github"

# Fetch from both remotes
git fetch "$ADO_REMOTE" --prune
git fetch "$GITHUB_REMOTE" --prune

# Get list of branches from ADO
mapfile -t BRANCHES < <(git branch -r | grep "^  ${ADO_REMOTE}/" | \
                        grep -v "HEAD" | sed "s|^  ${ADO_REMOTE}/||" | sort)

# Process each branch
for BRANCH in "${BRANCHES[@]}"; do
    echo "Processing: $BRANCH"

    # Checkout the branch from ADO
    git checkout -b "$BRANCH" "${ADO_REMOTE}/${BRANCH}" 2>/dev/null || \
    git checkout "$BRANCH"

    # Push to GitHub
    git push "$GITHUB_REMOTE" "$BRANCH" --force-with-lease
done
Enter fullscreen mode Exit fullscreen mode

Simple, right? Well, not quite. We encountered several real-world issues.

Challenge #1: The "Stale Info" Error

When using --force-with-lease (a safer alternative to --force), Git checks if the remote branch has changed since you last fetched. If there's a mismatch, you get:

! [rejected]  branch-name -> branch-name (stale info)
error: failed to push some refs
Enter fullscreen mode Exit fullscreen mode

Solution: Fetch from both remotes before starting the migration:

# Fetch to update remote tracking information
git fetch "$ADO_REMOTE" --prune
git fetch "$GITHUB_REMOTE" --prune
Enter fullscreen mode Exit fullscreen mode

For a one-time migration where ADO is the source of truth, you can also use --force instead of --force-with-lease:

PUSH_STRATEGY="force"  # or "safe" for --force-with-lease

if [ "$PUSH_STRATEGY" = "force" ]; then
    git push "$GITHUB_REMOTE" "$BRANCH" --force
else
    git push "$GITHUB_REMOTE" "$BRANCH" --force-with-lease
fi
Enter fullscreen mode Exit fullscreen mode

Challenge #2: Windows Git Bash Compatibility

When running on Windows with Git Bash, the script would stop after processing just one branch. This was due to:

  1. Line ending issues (CRLF vs LF)
  2. Array handling differences in bash on Windows
  3. The set -e flag causing early exit on any error

Solution: Use mapfile to properly handle arrays and avoid set -e:

# DON'T use: set -e  # This causes the script to exit on first error

# DO use: mapfile for array handling
mapfile -t BRANCHES < <(git branch -r | grep "^  ${ADO_REMOTE}/" | \
                        grep -v "HEAD" | sed "s|^  ${ADO_REMOTE}/||")

# Process with explicit error checking
for BRANCH in "${BRANCHES[@]}"; do
    git checkout "$BRANCH" > /dev/null 2>&1
    if [ $? -ne 0 ]; then
        echo "Failed to checkout $BRANCH"
        continue  # Keep going!
    fi
    # ... rest of the logic
done
Enter fullscreen mode Exit fullscreen mode

Challenge #3: Tracking Migration Results

You need to know:

  • Which branches were successfully migrated
  • Which were skipped (already up-to-date)
  • Which failed

Solution: Use arrays to track results:

declare -a MIGRATED_BRANCHES
declare -a SKIPPED_BRANCHES
declare -a FAILED_BRANCHES

# During migration
if push_successful; then
    MIGRATED_BRANCHES+=("$BRANCH")
elif already_up_to_date; then
    SKIPPED_BRANCHES+=("$BRANCH")
else
    FAILED_BRANCHES+=("$BRANCH")
fi

# Show results at the end
echo "Successfully migrated branches:"
for branch in "${MIGRATED_BRANCHES[@]}"; do
    echo "  ✓ $branch"
done
Enter fullscreen mode Exit fullscreen mode

The Complete Solution

Here's the full production-ready script:

#!/bin/bash

# Configuration
ADO_REMOTE="ado"
GITHUB_REMOTE="github"
PUSH_STRATEGY="force"  # or "safe"

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

# Tracking arrays
declare -a MIGRATED_BRANCHES
declare -a SKIPPED_BRANCHES
declare -a FAILED_BRANCHES

SUCCESS_COUNT=0
FAILED_COUNT=0
SKIPPED_COUNT=0

echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}Git Branch Migration Script${NC}"
echo -e "${BLUE}From: $ADO_REMOTE -> To: $GITHUB_REMOTE${NC}"
echo -e "${BLUE}========================================${NC}\n"

# Verify remotes
for remote in "$ADO_REMOTE" "$GITHUB_REMOTE"; do
    if ! git remote | grep -q "^${remote}$"; then
        echo -e "${RED}Error: Remote '$remote' not found!${NC}"
        exit 1
    fi
done

# Fetch from both remotes
echo -e "${YELLOW}Fetching from $ADO_REMOTE...${NC}"
git fetch "$ADO_REMOTE" --prune

echo -e "${YELLOW}Fetching from $GITHUB_REMOTE...${NC}"
git fetch "$GITHUB_REMOTE" --prune

# Get branches
mapfile -t BRANCHES < <(git branch -r | grep "^  ${ADO_REMOTE}/" | \
                        grep -v "HEAD" | sed "s|^  ${ADO_REMOTE}/||" | sort)

TOTAL=${#BRANCHES[@]}
echo -e "${GREEN}Found $TOTAL branches to migrate${NC}\n"

# Save original branch
ORIGINAL_BRANCH=$(git branch --show-current)

# Process each branch
COUNTER=1
for BRANCH in "${BRANCHES[@]}"; do
    [ -z "$BRANCH" ] && continue

    echo -e "${BLUE}[$COUNTER/$TOTAL]${NC} Processing: ${YELLOW}$BRANCH${NC}"

    # Check if already up-to-date
    GITHUB_CHECK=$(git ls-remote --heads "$GITHUB_REMOTE" "$BRANCH" 2>&1)
    if [ $? -eq 0 ] && echo "$GITHUB_CHECK" | grep -q "$BRANCH"; then
        ADO_COMMIT=$(git rev-parse "${ADO_REMOTE}/${BRANCH}" 2>&1)
        GITHUB_COMMIT=$(git rev-parse "${GITHUB_REMOTE}/${BRANCH}" 2>&1)

        if [ "$ADO_COMMIT" = "$GITHUB_COMMIT" ]; then
            echo -e "  ${GREEN}✓ Already up-to-date, skipping${NC}"
            ((SKIPPED_COUNT++))
            SKIPPED_BRANCHES+=("$BRANCH")
            ((COUNTER++))
            continue
        fi
    fi

    # Checkout branch
    if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
        git checkout "$BRANCH" > /dev/null 2>&1
        git reset --hard "${ADO_REMOTE}/${BRANCH}" > /dev/null 2>&1
    else
        git checkout -b "$BRANCH" "${ADO_REMOTE}/${BRANCH}" > /dev/null 2>&1
    fi

    if [ $? -ne 0 ]; then
        echo -e "  ${RED}✗ Failed to checkout${NC}"
        ((FAILED_COUNT++))
        FAILED_BRANCHES+=("$BRANCH")
        ((COUNTER++))
        continue
    fi

    # Push to GitHub
    if [ "$PUSH_STRATEGY" = "force" ]; then
        PUSH_FLAG="--force"
    else
        PUSH_FLAG="--force-with-lease"
    fi

    git push "$GITHUB_REMOTE" "$BRANCH" $PUSH_FLAG > /dev/null 2>&1
    if [ $? -eq 0 ]; then
        echo -e "  ${GREEN}✓ Successfully pushed${NC}"
        ((SUCCESS_COUNT++))
        MIGRATED_BRANCHES+=("$BRANCH")
    else
        echo -e "  ${RED}✗ Failed to push${NC}"
        ((FAILED_COUNT++))
        FAILED_BRANCHES+=("$BRANCH")
    fi

    ((COUNTER++))
    echo ""
done

# Return to original branch
git checkout "$ORIGINAL_BRANCH" > /dev/null 2>&1

# Summary
echo -e "\n${BLUE}========================================${NC}"
echo -e "${BLUE}Migration Summary${NC}"
echo -e "${BLUE}========================================${NC}"
echo -e "${GREEN}✓ Successfully migrated: ${SUCCESS_COUNT}${NC}"
echo -e "${YELLOW}⚠ Skipped: ${SKIPPED_COUNT}${NC}"
echo -e "${RED}✗ Failed: ${FAILED_COUNT}${NC}"

if [ ${#MIGRATED_BRANCHES[@]} -gt 0 ]; then
    echo -e "\n${GREEN}Successfully migrated:${NC}"
    for branch in "${MIGRATED_BRANCHES[@]}"; do
        echo -e "  ✓ $branch"
    done
fi

if [ ${#SKIPPED_BRANCHES[@]} -gt 0 ]; then
    echo -e "\n${YELLOW}Skipped (already up-to-date):${NC}"
    for branch in "${SKIPPED_BRANCHES[@]}"; do
        echo -e "  ⚠ $branch"
    done
fi

if [ ${#FAILED_BRANCHES[@]} -gt 0 ]; then
    echo -e "\n${RED}Failed:${NC}"
    for branch in "${FAILED_BRANCHES[@]}"; do
        echo -e "  ✗ $branch"
    done
fi

echo -e "\n${GREEN}Migration complete!${NC}"
Enter fullscreen mode Exit fullscreen mode

Usage

  1. Setup your remotes:
git remote add ado 
git remote add github 
Enter fullscreen mode Exit fullscreen mode
  1. Configure the script: Edit the variables at the top:
ADO_REMOTE="ado"           # Your ADO remote name
GITHUB_REMOTE="github"     # Your GitHub remote name
PUSH_STRATEGY="force"      # Use "force" for one-time migration
Enter fullscreen mode Exit fullscreen mode
  1. Run it:
chmod +x migrate_branches.sh
./migrate_branches.sh
Enter fullscreen mode Exit fullscreen mode
  1. Save the report (optional):
./migrate_branches.sh 2>&1 | tee migration_report.txt
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Test First

Run the script on a test repository before using it on production code.

2. Communication

Inform your team before starting the migration to avoid conflicts.

3. Choose the Right Strategy

  • Use PUSH_STRATEGY="safe" if multiple people might be pushing to GitHub during migration
  • Use PUSH_STRATEGY="force" for one-time migrations where ADO is the source of truth

4. Authentication

For GitHub Enterprise, you may need to configure authentication:

# For HTTPS
git config --global credential.helper cache

# Or use a personal access token in your URL
https://username:token@github.company.com/org/repo.git
Enter fullscreen mode Exit fullscreen mode

5. Handle Failed Branches

If any branches fail, you can migrate them manually:

git checkout -b failed-branch ado/failed-branch
git push github failed-branch --force
Enter fullscreen mode Exit fullscreen mode

Troubleshooting

Issue: Script stops after one branch

Cause: Line ending issues or set -e flag

Fix: Use mapfile and avoid set -e

Issue: "stale info" errors

Cause: Remote tracking information is outdated

Fix: Fetch from both remotes before migration, or use --force

Issue: Authentication failures

Cause: Missing or expired credentials

Fix:

# Clear and reconfigure credentials
git config --global --unset credential.helper
git config --global credential.helper cache

# Test authentication
git ls-remote github
Enter fullscreen mode Exit fullscreen mode

Issue: TLS/SSL certificate warnings

Cause: Self-signed certificates in enterprise environments

Fix:

# Temporary (not recommended for production)
git config --global http.sslVerify false

# Better: Add the certificate to your trust store
Enter fullscreen mode Exit fullscreen mode

Simplified Alternative

If you encounter issues with the full script, here's a simplified version that uses a temporary file:

#!/bin/bash

ADO_REMOTE="ado"
GITHUB_REMOTE="github"

# Get branches to a file
git branch -r | grep "^  ${ADO_REMOTE}/" | grep -v "HEAD" | \
  sed "s|^  ${ADO_REMOTE}/||" > /tmp/branches.txt

# Process each line
while IFS= read -r BRANCH; do
    [ -z "$BRANCH" ] && continue

    echo "Processing: $BRANCH"
    git checkout -b "$BRANCH" "${ADO_REMOTE}/${BRANCH}" 2>/dev/null || \
      git checkout "$BRANCH"
    git push "$GITHUB_REMOTE" "$BRANCH" --force
done < /tmp/branches.txt

rm /tmp/branches.txt
Enter fullscreen mode Exit fullscreen mode

Results

After running this script on our 62-branch repository, we got:

  • 58 branches successfully migrated
  • ⚠️ 3 branches skipped (already up-to-date)
  • 1 branch failed (required manual intervention)

Total time: ~5 minutes vs. several hours of manual work!

Conclusion

Automating Git branch migrations saves time and reduces errors. The key lessons:

  1. Fetch first to avoid stale info errors
  2. Handle Windows compatibility with proper array handling
  3. Track results for audit trails and documentation
  4. Choose appropriate force flags based on your scenario
  5. Test in a safe environment before production

The complete script is available as a GitHub Gist (link in comments). Feel free to adapt it for your needs!

Have you faced similar migration challenges? Share your experiences in the comments! 👇

Top comments (0)