No description
  • Shell 76.4%
  • Python 23.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Christoph Görn 652f64f73a
fix: 🐛 treat pre-existing team access as done, not as failure
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.
2026-07-30 00:32:59 +02:00
.pre-commit chore: 🔧 enforce shell lint, formatting and SPDX headers via pre-commit 2026-07-29 23:17:01 +02:00
scripts fix: 🐛 treat pre-existing team access as done, not as failure 2026-07-30 00:32:59 +02:00
tests fix: 🐛 treat pre-existing team access as done, not as failure 2026-07-30 00:32:59 +02:00
.gitignore chore: 🔒️ gitignore .envrc and .mcp.json, pin the self-install 2026-07-30 00:13:28 +02:00
.pre-commit-config.yaml test: add mock-API test suite and gate pushes on it 2026-07-29 23:28:50 +02:00
LICENSE init: 🎉 forgejo-migrate skill for Forgejo-to-Forgejo migration 2026-07-29 23:13:26 +02:00
README.md feat: send auth_token only when the source repo is private 2026-07-30 00:13:22 +02:00
SKILL.md feat: send auth_token only when the source repo is private 2026-07-30 00:13:22 +02:00
skills-lock.json chore: 🔒️ gitignore .envrc and .mcp.json, pin the self-install 2026-07-30 00:13:28 +02:00

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.sh requires APPLY=1; scan-urls.sh only 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.sh sends FORGEJO_SRC_TOKEN to the target server as auth_token so it can pull private repositories — and only then. A public source clones anonymously, so no token is disclosed. Override with MIGRATE_AUTH=true (always send) or MIGRATE_AUTH=false (never send). When the token is sent, only migrate into an instance you trust with it. DRY_RUN=1 redacts it from the printed payload.
  • Bundles produced by export-settings.sh contain 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.