- Shell 76.4%
- Python 23.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Applying a bundle to a repo in an org whose Owners team (or any org-level team) already reaches it died with HTTP 422 "team is already added to repo", aborting the run after settings and protections had been applied — so the script was not re-runnable, which the rest of it takes care to be. Found migrating b4mad/semantic-release into agentic-forges, where Owners and src-ops both inherit access. - Query the target's teams first and skip the ones already present, matching how hooks and protections already behave. - The mock now returns 422 on a duplicate team add instead of silently appending, so this cannot regress unnoticed. |
||
| .pre-commit | ||
| scripts | ||
| tests | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| LICENSE | ||
| README.md | ||
| SKILL.md | ||
| skills-lock.json | ||
forgejo-migrate
An agent skill — plus the shell scripts it drives — for moving repositories between two Forgejo instances and cleaning up the state the migration API leaves behind.
Forgejo's POST /api/v1/repos/migrate copies code, issues, PRs, releases, labels
and milestones. It does not copy webhooks, branch protection rules, repo
settings, collaborator permissions or team access. This project handles both
halves: the migration, and the restoration of everything migration drops.
The scripts are usable standalone — the skill is just the agent-facing instructions for driving them in the right order. See SKILL.md.
Requirements
bash, curl, jq, git. ripgrep is used when present.
Quick start
export FORGEJO_SRC_URL=https://git.old.example
export FORGEJO_SRC_TOKEN=... # read:repository, read:organization, read:user
export FORGEJO_DST_URL=https://git.new.example
export FORGEJO_DST_TOKEN=... # write:repository, write:organization
scripts/export-settings.sh oldorg/widget > bundles/widget.json # BEFORE migrating
DRY_RUN=1 scripts/migrate-repo.sh oldorg/widget neworg # inspect payload
scripts/migrate-repo.sh oldorg/widget neworg
HOOK_SECRET=... scripts/apply-settings.sh bundles/widget.json neworg/widget
scripts/verify.sh oldorg/widget neworg/widget # exit 1 on drift
Every script prints its own documentation when run with no arguments.
Scripts
| Script | Purpose |
|---|---|
lib.sh |
Shared API client, pagination, env validation. Sourced, not run. |
discover.sh |
List source repos for an org (--user, --mine) as JSONL. |
export-settings.sh |
Dump settings, topics, hooks, protections, collaborators + permissions, teams. |
migrate-repo.sh |
Server-side migration of one repo. DRY_RUN=1 to inspect. |
apply-settings.sh |
Replay a bundle onto the target. DRY_RUN=1, SKIP=, HOOK_SECRET=. |
verify.sh |
Diff source against target; exits 1 on drift so it can gate a loop. |
fix-remotes.sh |
Repoint git remote URLs in local clones. Dry-run unless APPLY=1. |
scan-urls.sh |
Report hardcoded old-host references in repo content. Reports only. |
Design rules
These are deliberate and worth knowing before you rely on the tooling:
- Export before migrating. A bundle pulled from a half-migrated target is worthless.
- Nothing is ever deleted. No script issues a
DELETE, and decommissioning the source is a separate, explicit act. - Destructive-ish local edits dry-run by default.
fix-remotes.shrequiresAPPLY=1;scan-urls.shonly ever reports, because blanket-sed-ing URLs across a repo breaks Go module paths and other things you meant to keep. - Credentials are validated up front. A refusal to start beats a half-run migration.
- Existing target repo → HTTP 409 and stop. No auto-retry under a mangled name.
What migration cannot carry over
Secrets are write-only in the Forgejo API, so no tool can move them. Plan for it:
| Lost | Action |
|---|---|
| Webhook secrets | Pass HOOK_SECRET=, or hooks are created secretless and signature-verifying consumers reject payloads |
| Actions secrets | Names are listable, values are not — re-enter by hand |
| Deploy keys (private half) | Regenerate the key pair |
| Issue/PR author identity | Attributed to the token owner with an "originally by" note |
| Packages, Actions run history | Republish, or accept the loss |
Security notes
migrate-repo.shsendsFORGEJO_SRC_TOKENto the target server asauth_tokenso it can pull private repositories — and only then. A public source clones anonymously, so no token is disclosed. Override withMIGRATE_AUTH=true(always send) orMIGRATE_AUTH=false(never send). When the token is sent, only migrate into an instance you trust with it.DRY_RUN=1redacts it from the printed payload.- Bundles produced by
export-settings.shcontain no secrets, but do describe your infrastructure (webhook URLs, collaborators, teams). They are gitignored by default.
Development
pre-commit install --hook-type pre-commit --hook-type pre-push # once
pre-commit run --all-files # shellcheck, shfmt, SPDX headers, whitespace, JSON
tests/run-tests.sh # the test suite; -v to echo command output
Lint runs on commit; the test suite runs on push, since five seconds is too long to pay per commit.
shfmt rewrites files in place, so run it before proposing changes rather than
arguing with it afterwards. scripts/lib.sh intentionally has neither a shebang
nor an exec bit — it is sourced, and the shellcheck shell=bash directive tells
the linter its dialect.
Testing
tests/run-tests.sh runs 93 assertions against two mock Forgejo instances
(tests/mock_forgejo.py, on ephemeral ports) and real throwaway git repositories.
It covers the argument and credential guards, dry-run paths writing nothing, token
redaction, unit-flag inheritance, HTTP 409 on an existing target, idempotent
re-apply, drift detection in both directions, and remote rewriting across https,
scp-style and ssh:// URLs. No network access or live instance needed.
The suite has been mutation-tested: breaking the HOOK_SECRET injection, the
permission=none skip, or the fix-remotes.sh dry-run guard each turn it red.
Not covered without a real instance: acceptance of the service field value, the
exact branch_protections POST schema, GET /users/{username}/repos, and actual
git/LFS data transfer. Treat the first real migration as the integration test — and
run it against a throwaway target repo first.
Repository
Published on Radicle. The Repository ID (RID) is:
rad:z4j8nGpn5HiWGmjVempT6vaxQHy9
rad clone rad:z4j8nGpn5HiWGmjVempT6vaxQHy9
Development happens on git.b4mad.industries, which push-mirrors to https://github.com/goern/forgejo-migrate.
The GitHub repository is a read-only mirror. It is overwritten by the next sync, so anything pushed there directly is lost. Open issues and pull requests against Radicle or the Forgejo repository instead.
Licence
GPL-3.0-or-later. See LICENSE.
Copyright © 2026 Christoph Görn.