joflux moves the repositories of a GitHub organization into a Forgejo-compatible instance such as Codeberg. It exports an inventory of the organization, starts a Forgejo migration for every repository in it, waits for them to finish, checks what arrived, and archives the GitHub originals once you're happy with the result.
Each of those steps is its own command, and each one writes its results as JSON in
migration_output/. You can read exactly what happened, re-run a stage, and keep
the files as the record of the move. joflux is a reworked public version of an
older internal GitHub-to-Codeberg helper, and the default install needs nothing
beyond the Python 3.11+ standard library.
Quick links: Install · Try it · Docs · The five stages · CLI reference · Releases
On macOS or Linux, Homebrew is the easiest route:
brew tap netspeedy/joflux
brew install jofluxOn recent Homebrew, new third-party taps require explicit trust. If installation is refused, run
brew trust netspeedy/jofluxonce, thenbrew install joflux.
Or install it from a checkout with pip:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install .YAML configs need the optional extra, python -m pip install '.[yaml]'. TOML
needs nothing extra.
Copy the example config, set your GitHub and Forgejo organizations in it, put both
tokens in the environment, and export the inventory. export only reads from
GitHub, so it's the safe first step:
cp joflux.toml.example joflux.toml
export JOFLUX_GITHUB_TOKEN="ghp_your_github_token"
export JOFLUX_FORGEJO_TOKEN="your_forgejo_token"
joflux --config joflux.toml exportIt lists the organization and saves the inventory:
02:39:52 - INFO - Fetching repositories from GitHub org: my-github-org
02:39:52 - INFO - Found 14 repositories
02:39:52 - INFO - Inventory saved: migration_output/repos-inventory.json
02:39:52 - INFO - Exported 14 repositories (3 private, 1 forks, 1 archived)
Read migration_output/repos-inventory.json before going any further. If forks or
already archived repositories shouldn't move, export again with --exclude-forks
or --exclude-archived; the inventory is the list every later stage works from.
Run the stages in order, all with the same config:
joflux --config joflux.toml export
joflux --config joflux.toml migrate
joflux --config joflux.toml monitor
joflux --config joflux.toml verify
joflux --config joflux.toml archive| Stage | What it does | Writes |
|---|---|---|
export |
Lists every repository in the GitHub organization | repos-inventory.json |
migrate |
Resolves the target organization and runs one Forgejo migration per repository, waiting for each to finish | migration-log.json |
monitor |
Rechecks any migration Forgejo didn't answer for while migrate was waiting |
migration-status.json |
verify |
Checks each target repository and records its size, its issue, pull request, label and release counts, and whether it has a wiki | verification-report.json |
archive |
Archives the GitHub originals that verify recorded as verified |
archive-log.json |
migrate copies each repository as a normal repository rather than a mirror and
keeps it private if it was private on GitHub. Forgejo brings the wiki, issues, pull
requests, labels, milestones and releases across with the git data; set
metadata = false under [migration] if you only want the git data. Forgejo
answers a migrate request only once the import has finished, so migrate records
each repository as completed or failed as it goes, waiting up to
max_wait_time seconds for each. If no answer comes back in that time, or a proxy
in front of Forgejo gives up first, the repository is recorded as initiated and
monitor checks it. Forgejo's API can show that the git data has arrived but not
whether the rest of the import has finished, so once the git data is there
monitor marks the repository unconfirmed and exits 1; check it in the Forgejo
web UI before you go on. You can stop monitor and run it again, and --interval
overrides the poll interval for that run. verify exits with code 1 and names the
missing repositories if any of them aren't on the target.
archive is deliberately separate from the rest. It only archives repositories
whose latest entry in verification-report.json is verified, and won't start
without that report. It asks you to type yes before it changes anything, unless
you pass --yes, archives the originals rather than deleting them, and skips any
that are already archived. Each stage has its own page in the
docs, covering what it does, what it
writes and what to check before the next stage.
Every command exits 0 on success, 1 when a stage partly failed or an API call errored, and 2 for a config problem or a missing state file, so a script can stop at the first stage that didn't go cleanly.
joflux reads joflux.toml by default; --config points it elsewhere and
--log-level overrides the configured log level for one run.
[github]
org = "my-github-org"
# token = "ghp_your_github_token"
[forgejo]
url = "https://codeberg.org"
org = "my-forgejo-org"
# token = "your_forgejo_or_codeberg_token"
[migration]
poll_interval = 30
max_wait_time = 3600
log_level = "INFO"
output_dir = "migration_output"
metadata = trueThe GitHub token needs to list and clone the organization's repositories,
including any private ones you're moving, and to read their issues and pull
requests, because Forgejo fetches those with it. It also needs to update repository
settings if you'll run archive. The Forgejo or Codeberg token needs to create
repositories in the target organization, which must exist before you run
migrate. Keep both in the environment as JOFLUX_GITHUB_TOKEN and
JOFLUX_FORGEJO_TOKEN rather than in the file; for Codeberg,
JOFLUX_CODEBERG_TOKEN works too. A token in the config file takes precedence
when both are set. For GitHub Enterprise Server, set github.api_url to your
server's API address.
Flat keys from the older tool, such as github_org, codeberg_url,
codeberg_org and codeberg_token, still work, so an earlier config can be moved
across gradually. Config keys
in the docs lists every key.
Forgejo instances differ in what they allow. Some restrict private imports,
metadata import, how many repositories an organization can hold, or how many
migrations run at once, so check the target's limits before starting on a large
organization. Importing issues and pull requests takes far longer than copying git
data and spends the GitHub token's API quota; Forgejo pauses until the quota resets
when it runs out, and migrate waits up to max_wait_time seconds for each
repository. If the target won't take metadata imports, set metadata = false. If
the target sits behind a reverse proxy, the proxy's read timeout has to be longer
than your slowest import; otherwise it answers 504 while Forgejo is still working,
and joflux can only record that import as unconfirmed. Keep migration_output/
once the run is over: it's the audit trail, and it's what monitor, verify and
archive read when you re-run them.
Install the development tools and run the checks:
make install-dev
make fmt-check
make lint
make test
make smokemake fmt formats with black and isort, and make build builds the package. The
docs site is built with Astro and Starlight
from website/, and its README covers the make website-*
targets and how the site is published. If you add, rename or remove a CLI option,
update the CLI reference page in the same change: tests/test_docs.py fails until
the page matches. docs/release.md describes the release flow.
The banner at the top of this page and the social cards are generated. Edit the
copy, layout or timing in scripts/readme/build_readme_hero.py and run make readme-hero. That rebuilds assets/joflux-readme-hero.svg, the website's share
card, mark and icons, and assets/joflux-social-card.png, which is the
image for GitHub's social preview and is uploaded by hand in the repository
settings. It runs the script with uv, which installs the pinned fontTools it
subsets the fonts with, and renders the cards with headless Chrome or Chromium. The
fonts come from assets.netspeedy.io, so the first run needs network access. If you
change the headline's words, measure them again with uv run scripts/readme/measure_headline.py.
Copyright © 2026 Simon Oakes. Released under the MIT License.