Mirror all your GitHub repositories to GitLab automatically — private stays private, public stays public.
Having all your code on a single platform is a risk. This tool clones every repository from your GitHub account and creates matching repositories on GitLab, preserving visibility exactly. Run it once for a full backup, run it again anytime to sync incrementally.
- Visibility-safe — private repos are never created as public; defaults to private when in doubt
- Idempotent — safe to re-run; existing GitLab repos receive incremental pushes, not duplicates
- Full mirror — clones all branches and tags via
git clone --mirror - Wiki backup — mirrors each repo's wiki (a separate git repo) to GitLab (opt-in)
- Issue migration — copies GitHub Issues with labels, milestones, and comments to GitLab (opt-in)
- Dry-run mode — preview every action before executing a single write
- Selective backup — filter by glob pattern (
--filter "myproject-*") - Works on Windows — handles read-only
.gitfiles; tested on Windows 10 / PowerShell - Rich terminal output — progress bar and per-repo status table
- Python 3.11+
gitin your PATH- SSH key registered on both GitHub and GitLab
- GitHub token with
reposcope - GitLab token with
apiscope
pip install gh2gl
gh2gl init
# Creates a github-backup/ folder in the current directory
cd github-backup
# Edit config.yaml — set github.username and gitlab.username
# Edit .env — set GITHUB_TOKEN and GITLAB_TOKEN
gh2gl --dry-run # preview — no changes made
gh2gl # run the backupRunning from source
git clone git@github.com:paladini/backup-github-to-gitlab.git
cd backup-github-to-gitlab
pip install -e .- Go to Settings → Developer settings → Personal access tokens → Tokens (classic)
- Click Generate new token (classic)
- Select scope:
repo(required to list private repositories) - Paste the token into
.envasGITHUB_TOKEN
- Go to User Settings → Access Tokens → Add new token
- Select scope:
api(required to create projects) - Paste the token into
.envasGITLAB_TOKEN
After running gh2gl init, two files are created in the github-backup/ folder:
config.yaml:
github:
username: your-github-username
gitlab:
username: your-gitlab-username # can differ from GitHub
url: https://gitlab.com # change only for self-hosted GitLab
backup:
include_forks: false # include forked repositories? (default: false)
include_archived: true # include archived repositories? (default: true)
# temp_dir: ./tmp # temporary dir for clones — auto-cleaned after each repo
# Extended backup (opt-in)
backup_wiki: false # mirror each repo's wiki to GitLab
backup_issues: false # migrate GitHub Issues to GitLab.env:
GITHUB_TOKEN=ghp_...
GITLAB_TOKEN=glpat-...
Tokens are loaded at runtime and never logged. .env is in .gitignore.
Run all commands from inside the github-backup/ folder (or any folder with config.yaml and .env):
# Back up all personal repositories
gh2gl
# Preview what would happen — no writes
gh2gl --dry-run
# Back up only repos matching a pattern
gh2gl --filter "myproject-*"
# Include forks (skipped by default)
gh2gl --include-forks
# Verbose: show raw git output (useful for debugging SSH issues)
gh2gl --verbose
# Use a different config file
gh2gl --config /path/to/config.yamlDRY RUN MODE — no changes will be made
my-private-repo ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3/10
Repository Status Details
my-private-repo ~ dry run would create as private
my-public-site ~ dry run would create as public
archived-experiment ~ dry run would create as private
10 dry run
[DRY RUN] No operations were executed.
Private repositories on GitHub are always created as private on GitLab. Visibility is derived exclusively from the GitHub API response — there is no code path that can promote a private repository to public. If visibility cannot be determined, the tool defaults to private and logs a warning.
SSH keys are used for all git operations. API tokens are read from environment variables (.env), never from the config file, and never written to logs.
Enable in config.yaml:
backup:
backup_wiki: true
backup_issues: trueWiki backup clones each repo's wiki as a separate git repository and pushes it to the corresponding GitLab wiki. Repos without a wiki are skipped silently. Fully idempotent (git push --mirror).
Issue migration copies GitHub Issues (open and closed) to GitLab, including labels, milestones, and comments. Pull Requests are not migrated.
Known limitations for issue migration:
- Timestamps are not preserved. GitLab's API does not allow setting
created_atwithout an admin token. All migrated issues will show the migration date.- Authors are not preserved. All issues and comments will be attributed to the token owner in GitLab. The original author and date are recorded at the top of each issue/comment body.
- Issue migration is idempotent. A hidden marker (
<!-- github-issue-id: N -->) is embedded in each migrated issue. Re-running the script skips already-migrated issues.
| Limitation | Details |
|---|---|
| Git LFS | LFS objects are not transferred (git clone --mirror skips them) |
| Organization repos | Not included in v1; planned for v2 with --include-orgs |
| GitLab pull mirroring | Requires GitLab Premium for private repos; a GitHub Actions alternative is planned for v2 |
| Issue timestamps/authors | Cannot be preserved without a GitLab admin token (see Wiki & Issue Backup above) |
| Pull Requests | Not migrated (GitLab Merge Requests have different semantics) |
- v1 — Full backup: clone + push via SSH, idempotent, dry-run, selective filter
- v1.5 — Wiki mirroring and GitHub Issues migration (opt-in)
- v2 — Auto-sync: GitLab pull mirror (Premium) or GitHub Actions push mirror
- v2 — Organization repos with explicit opt-in
Issues and pull requests are welcome. Please open an issue first to discuss what you'd like to change.
MIT — see LICENSE.