Import a Forgejo instance into Nestejo, flat organisations into nested groups
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-18 11:37:43 -04:00
.gitignore Import a Forgejo or Gitea instance into nested groups 2026-10-18 11:37:43 -04:00
forgejo2nestejo.py Import a Forgejo or Gitea instance into nested groups 2026-10-18 11:37:43 -04:00
LICENSE Import a Forgejo or Gitea instance into nested groups 2026-10-18 11:37:43 -04:00
README.md Import a Forgejo or Gitea instance into nested groups 2026-10-18 11:37:43 -04:00

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-orgs use 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 = true on 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-groups allows 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.