A PHP command-line tool that migrates a self-hosted GitLab instance to Forgejo using both REST APIs. It can migrate:
- Users — creates Forgejo users for every GitLab user
- Organisations — creates Forgejo orgs for every GitLab group (subgroups are flattened)
- Repositories — imports every GitLab project into Forgejo, including branches, tags, and optionally wiki and Git LFS
Requires PHP >= 8.1 with the curl and json extensions.
composer install| What | Why |
|---|---|
GitLab personal access token with the api scope, belonging to an administrator user |
Listing all users, groups and projects requires admin access |
Forgejo access token with the admin scope |
Creating users/orgs and migrating repos into other owners requires site admin |
| Network access from the Forgejo server to the GitLab instance | Repositories are cloned by Forgejo itself (POST /api/v1/repos/migrate), not by this tool |
# 1. Preview everything without changing anything
php bin/migrate \
--gitlab-url https://gitlab.example.com \
--gitlab-token <gitlab-pat> \
--forgejo-url https://forgejo.example.com \
--forgejo-token <forgejo-admin-token> \
--all --dry-run
# 2. Run the migration
php bin/migrate \
--gitlab-url https://gitlab.example.com \
--gitlab-token <gitlab-pat> \
--forgejo-url https://forgejo.example.com \
--forgejo-token <forgejo-admin-token> \
--allValues can also be provided via environment variables: GITLAB_URL, GITLAB_TOKEN, FORGEJO_URL, FORGEJO_TOKEN.
| Option | Description |
|---|---|
--users |
Migrate users |
--orgs |
Migrate organisations (GitLab groups) |
--repos |
Migrate repositories (git data, branches, tags, wiki, LFS) |
--all |
Shortcut for --users --orgs --repos |
--dry-run |
Show what would be done without making any changes |
The recommended order is users → orgs → repos (which is what --all does), because repositories need their owners to exist to be assigned correctly.
| Option | Description |
|---|---|
--include-bots |
Migrate GitLab bot users (skipped by default) |
--include-inactive |
Migrate blocked/deactivated GitLab users (skipped by default) |
--include-forks |
Migrate forked repositories (skipped by default) |
--include-archived |
Migrate archived repositories (skipped by default) |
--visibility=public|internal|private |
Only migrate repositories with this visibility |
--filter='/regex/' |
Only migrate repositories whose full path matches this PCRE regex (delimiters required), e.g. --filter='/^engineering\//' |
--limit=N |
Migrate at most N items per category (useful for testing) |
| Option | Description |
|---|---|
--owner=NAME |
Forgejo user that owns created orgs and receives repositories whose GitLab owner was not migrated (default: the user owning the Forgejo token) |
Owner resolution per repository:
- User-owned project → the Forgejo user created in this run, or an existing Forgejo user with the same name
- Group-owned project → the Forgejo org created in this run, or an existing Forgejo org with the computed name
- Otherwise →
--owner, with a visible warning
| Option | Description |
|---|---|
--password=PASS |
Use this fixed password for all newly created users (default: a random password per user, printed at the end) |
--passwords-file=PATH |
Write generated user passwords to this CSV file (username,password) instead of printing them |
--must-change-password / --no-must-change-password |
Require created users to change their password on first login (default: on) |
--migrate-admins |
Make users that are GitLab admins into Forgejo site administrators |
| Option | Description |
|---|---|
--wiki / --no-wiki |
Migrate wikis (default: on) |
--lfs / --no-lfs |
Migrate Git LFS objects (default: on) |
--pull-requests / --no-pull-requests |
Migrate Pull Requests (default: on) |
--issues / --no-issues |
Migrate Git LFS objects (default: on) |
--migrate-timeout=SECONDS |
Timeout for each repository migration (default: 600). Increase for very large repositories |
- Idempotent: users, orgs and repos that already exist on Forgejo are skipped (not duplicated) and still used for owner mapping, so the command can be safely re-run after a partial migration.
- Visibility mapping: GitLab
public→ Forgejopublic,internal→limited,private→private. - Subgroups: Forgejo has no subgroups, so a GitLab group path is flattened into the org name:
engineering/frontend→engineering-frontend(a single hyphen, because consecutive dashes are invalid Forgejo names). Names are sanitized to[a-z0-9._-](max 30 chars); collisions get a_2,_3, … suffix. - Repo cloning: repositories are imported via Forgejo's migrate endpoint (
service=gitlab) using your GitLab token as the clone credential. The import runs server-side on Forgejo and blocks until the git data is cloned; if it fails, Forgejo deletes the partially created repository. - GitLab token scope for repos: the GitLab token must have
read_repository(covered byapi) and must be able to read every project being migrated. - Exit codes:
0when no item failed,1when any item failed or a phase was aborted (e.g. bad credentials, non-admin token). A final summary table and a failure list are always printed.
# Users only
php bin/migrate ... --users
# Orgs only (owned by the token user)
php bin/migrate ... --orgs
# Only public repos of the engineering groups
php bin/migrate ... --repos --visibility public --filter='/^engineering\//'Two mock API servers are included for local testing (no real GitLab/Forgejo needed):
# Terminal 1 — mock GitLab
php -S 127.0.0.1:8081 test/mock/gitlab.php
# Terminal 2 — mock Forgejo (persists to test/mock/state/forgejo.json)
php -S 127.0.0.1:8082 test/mock/forgejo.php
# Terminal 3 — run the migration
php bin/migrate --gitlab-url http://127.0.0.1:8081 --gitlab-token test \
--forgejo-url http://127.0.0.1:8082 --forgejo-token test --all
# Re-run to verify idempotency (everything should be skipped)
php bin/migrate ... --allRemove test/mock/state/ between experiments to start from a clean Forgejo.