- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .gitignore | ||
| forgejo2nestejo.py | ||
| LICENSE | ||
| README.md | ||
forgejo2nestejo
Imports a Forgejo or Gitea instance into Nestejo and places its organizations in
a group tree. Gitea uses the same API and is tested separately against a Gitea
instance. nestejo/migrations/gitea2nestejo is a mirror of this repository.
./forgejo2nestejo.py \
--src https://old.example --src-token "$SRC" \
--dst https://nestejo.example --dst-token "$DST" \
--all-orgs --user-repos \
--map 'acme=company/acme' \
--map 'acme-legacy=company/acme/archive' \
--db-shell 'ssh host docker exec -i nestejo sqlite3 /data/gitea/gitea.db'
Each organization is placed at the path given by --map, under --under GROUP,
or at the top level under its own name. Missing levels of a mapped path are
created: --map 'plans=company/secret/plans-dept' creates company,
company/secret and company/secret/plans-dept.
Repository contents, LFS objects, issues, pull requests, labels, milestones, releases and wikis are imported by Nestejo's migration API. LFS objects are imported only if the target serves LFS. Teams, team members, collaborators and branch protections are not carried by that API and are copied afterward.
The tool does not write to the source. A repeated run skips what already exists, so an interrupted import can be run again.
Requirements
- An administrator token on the source. Accounts and
--all-orgsuse the admin API. - An administrator token on the target, to create accounts. Use the account that is your administrator on the source, with the same login and email. On an empty target, create only that account first; the import creates the rest from the source. An account made only for the import stays on the target.
- A source served over plain http needs
[migrations] ALLOW_UNENCRYPTED = trueon the target. Nestejo 17 refuses unencrypted migrations by default. - To reassign authors, access to the target's database through
--db-shell, with the instance stopped. Without it, the tool writes the SQL to a file. - The target must reach the source at the URL the source advertises, not only at
the URL passed in
--src. See "Known issues".
Default branch
Each repository keeps the default branch it has on the source.
--default-branch NAME renames every imported repository's default branch to
NAME. Renaming moves open pull requests, branch protection rules and the
default branch setting with the branch. Protection rules for the old default
branch are moved to the new name. A repository that already has a branch called
NAME is left as it is and reported.
Not migrated
| Item | Behavior | Handling |
|---|---|---|
| Issue, comment, review and release authors | The migration API assigns every post to the ghost account. No API can change a poster. | Accounts are created first. --db-shell applies the reassignment. Without --db-shell, the SQL is written to --authors-sql. |
| Repository creation time and creator | The migration API dates each repository at the import, and a repository created empty shows the importing account as its creator in the activity feed. | --db-shell sets the source's creation time, and the creator found in the source's activity feed. Without --db-shell, the SQL is written to --authors-sql. |
| Repository last-updated time, account join dates, group creation dates | The import sets them to the time of the import. | --preserve-dates sets them to the source's. Applied through --db-shell or --authors-sql. |
| Issue assignees | Dropped by the migration API. | Set over the API after each repository, matched by issue number and title. |
| Passwords | Not readable from any source. | Accounts are created with a random password that must be changed at first login. --no-users disables account creation. To keep credentials, use the same OAuth or LDAP source on both instances. |
| SSH keys, personal tokens, webhook secrets | Not migrated. | Users add keys again. Webhook secrets are set by hand. |
| Actions secrets, variables, runners | Not migrated. | Set them on the target. |
| Packages | Not migrated. | Push them again from a client. |
Known issues
The target clones from the URL the source advertises. For the gitea
service, the target reads the repository from the source API and clones the
clone_url it returns, which is derived from the source's ROOT_URL. If that
URL is http://localhost:3000 or otherwise unreachable from the target, every
repository fails with Failed to connect. Set the source's ROOT_URL to an
address the target can reach before importing. --src-internal rewrites the
address sent to the target and only helps when the source already advertises a
reachable URL.
A failed migration leaves an empty repository. The repository record
remains after a failure, so a re-run sees the name as taken. The tool deletes an
empty repository and retries it. --keep-empty disables this.
A large import can outlast the request. The target answers a migration only
when it has finished. The tool waits up to --wait 2-second intervals (default
1800, one hour) for each repository. If no answer arrives in that time, the
target may still be importing, so the repository is reported and the run
continues. A repository that is empty on the target and was created less than
that long ago is treated as still importing on the next run: it is reported and
not deleted.
Authorship needs database access. The migration API reassigns posts only to
accounts with an external login record from an OAuth sign-in, which an import
does not create. The tool writes UPDATE statements for this, and --db-shell
runs them against the stopped instance. Only logins that exist on both
instances are changed.
Groups created to hold a path use --visibility. A private source
organization becomes a private group. A group created only to hold a mapped path
(company in company/acme) gets --visibility, which defaults to public.
Check parent groups after importing private content.
Access and visibility rules
- Access is never widened. When the source's permission has no exact equivalent, the narrower option is used and the difference is reported. Access a user has through a team is not also written as a direct grant.
- A group the import did not create is not made more visible. If the import
needs that, the run reports it and exits non-zero.
--open-existing-groupsallows it. - A group created only to hold a path gets the widest visibility of its contents, because a subgroup cannot be more visible than its parent. A subtree that is private throughout gets a private parent.
- An archived repository is unarchived, updated and archived again. If the run stops early, repositories it unarchived are archived again before exit.
- Reconciliation changes only settings this import sets. Settings changed by hand on the target, webhook secrets, and fields the import does not write are not changed.
Testing
../lab runs Forgejo, Gitea and GitLab in containers and resets the target
before each run. ../lab/suite-forgejo.sh and ../lab/suite-gitea.sh check:
- no content is lost or added
- the group structure and authorship match the mapping
- access is never wider than on the source
- a second run changes nothing
- failures exit non-zero and say what failed
- the source is unchanged, by comparing its API output before and after
Copyright (C) 2026 The Nestejo Authors. Free software under the GNU General Public License v3.0 or later; see LICENSE. Provided as is, without warranty of any kind, express or implied. Not affiliated with, endorsed by or supported by the Forgejo, Gitea or GitLab projects.