Migrate a self-hosted GitLab to GitLab SaaS (gitlab.com) — version-independent, with an interactive mapping UI in your terminal.
gitlab-copy-tool copies your repositories completely, with all branches and
tags, recreates the group/folder structure on the target, and optionally
transfers issues, merge requests, CI/CD variables and settings. The core runs
over the plain Git protocol, so it largely doesn't matter how far apart the
GitLab versions of source and target are.
self-hosted GitLab GitLab SaaS (gitlab.com)
┌───────────────────────────┐ ┌───────────────────────────────┐
│ 📁 tools (priv) │ git mirror │ 📁 example-org (public) │
│ ├─ deployment ──────────┼─── all branches ───▶│ └─ 📁 tools (priv) │
│ └─ 📁 ci │ + tags │ ├─ deployment │
│ └─ runner │ │ └─ 📁 ci │
└───────────────────────────┘ REST API v4 │ └─ runner │
(source) (groups, issues, └───────────────────────────────┘
MRs, CI vars, …) (target, freely mapped)
Repository content travels via git clone --mirror and a force+prune push of
all branches and tags; structure & metadata via the stable GitLab REST API v4.
The source GitLab is read-only by default. The tool clones from the source and creates/updates on the target only. The one exception is an explicit, opt-in action: pressing
ctrl+ain the mapping screen archives the already-transferred repos on the source (reversible). Deleting old repositories remains a manual step you perform yourself.
Prebuilt binaries for the latest release (no runtime dependency):
- 🐧 Linux (x86-64) — gitlab-copy-tool-linux-amd64
- 🐧 Linux (ARM64) — gitlab-copy-tool-linux-arm64
- 🍎 macOS (Apple Silicon) — gitlab-copy-tool-darwin-arm64
- 🍎 macOS (Intel) — gitlab-copy-tool-darwin-amd64
- 🪟 Windows (x86-64) — gitlab-copy-tool-windows-amd64.exe
On Linux/macOS make it executable (chmod +x gitlab-copy-tool-*). macOS may
quarantine an unsigned binary — clear it with
xattr -d com.apple.quarantine gitlab-copy-tool-darwin-arm64. Or
build from source.
The central screen is the mapping: on the left the source tree with
checkboxes and tree lines, on the right the resolved target per repo. Both the
target namespace and the optional steps can be set per repo or at the
group level (inherited by the substructure, nearest override wins). Press
enter on a node to open the target picker — filter existing target
namespaces, pick a source-derived suggestion, or type a free path (created if it
doesn't exist). The options block shows the settings for the highlighted node;
* marks an option set explicitly here, dim ones are inherited.
Mapping (source → target namespace, group target is inherited)
[~] 📁 tools ⇒ example-org/… (inherited)
▸ │ ├─ [x] deployment → example-org/tools/deployment ✓ transferred [archived]
│ └─ 📁 ci
│ └─ [x] runner → example-org/tools/ci/runner [force]
└─ 📁 legacy
└─ [ ] old-tool
Options for deployment (set with 1-6; * = set here, else inherited):
1 [x] Issues/MRs 2 [x] CI-Vars 3 [x] Settings
4 [x] URL-Rewrite 5 [x] Releases 6 [x] Container-Registry*
- All branches and tags are copied 1:1 (
git clone --mirror, then a force+prune push ofrefs/heads/*andrefs/tags/*) — version-independent, no API feature dependency. GitLab-internal hidden refs (merge-requests, pipelines, keep-around) are intentionally not pushed, since GitLab rejects them. - Existing-target guard: if the target repo already exists, its refs are
compared. If the target has nothing newer, it is fully overwritten. If it has
newer or divergent commits/branches, the repo is skipped with a reason
(data-loss protection) — overridable per repo with
f(force). - Selectable transport:
auto(SSH via your agent first, HTTPS token fallback),sshorhttps— per instance.
- Group-tree discovery: groups → subgroups → projects as a tree.
- Free mapping: target namespace per repo, or at the group level with cascade — a group target is inherited as a prefix by all subgroups/repos and preserves their substructure; individual entries stay overridable.
- Group creation: missing target (sub)groups are created automatically — with the exact name and path slug of the source (a group named "Public" with path "pub" stays that way).
- Visibility replication: private/public of source groups and repos is
replicated on the target and reconciled on every run (groups are only
raised, never lowered). GitLab SaaS has no
internallevel, so sourceinternalmaps toprivate(never public). Where GitLab forbids a level (e.g. public under a private target account), a warning is emitted instead of a hard failure.
These steps are toggleable per repo or per group (cascading to the substructure, nearest override wins); if one fails you only get a warning — the repo still counts as successfully migrated. Repos that actually have container images get the container registry step auto-enabled on discovery:
- Issues + open merge requests + labels + milestones
- Releases (name, description, tag, asset links, milestones — the tags already exist from the mirror; source archives are regenerated by GitLab)
- CI/CD variables
- Project settings (description)
- Container registry — copies all images + tags (incl. multi-arch) registry- to-registry in pure Go (via go-containerregistry); no external tool or Docker daemon required. Off by default; needs tokens with registry access. Shows a progress bar (tag i/N + current image) during the copy. A broken/empty source tag is detected up front and skipped (with a per-tag timeout) so it can't hang the run; the registry being disabled is skipped with a warning.
After the push, the old GitLab host in references is replaced — in every
composer.json (at any depth) and in all files in the repo root, as one
extra commit on the default branch:
- All URL forms:
https://,http://, scp-likegit@host:…andssh://git@host/…(the form is preserved). - Path carried over: for migrated repos the full new path is set (even when the namespace differs due to the mapping), not just the host.
- Account prefix: references to not-yet-migrated repos get the host swapped and the target account (first segment of the target namespace) prepended.
- Per-session path map: every successful run remembers
old → new, so later runs also correctly rewrite references to previously migrated repos.
- Incremental re-runs: an already-transferred repo is skipped (
unchanged) unless its config changed (target, options, force) or the source has new commits/branches. Each repo is cloned into a temp dir, mirror-pushed, and the temp dir is removed immediately — at most ~one repo is on disk at a time. - Sessions: connections, selection, mapping and options are saved and offered for reuse on startup.
- Dry run: shows the resolved plan without pushing anything.
- Non-interactive: saved sessions can be run scriptably (CI).
| Aspect | Path | Why |
|---|---|---|
| Repo content | git mirror clone/push |
plain Git protocol → version-independent, copies all refs |
| Structure & metadata | GitLab REST API v4 | very stable across versions |
| Fault tolerance | only group/project/mirror are "hard" | everything else warns and continues |
Requires Go 1.26+ and an installed git.
git clone https://github.com/bresam/gitlab-copy-tool.git
cd gitlab-copy-tool
go build -o gitlab-copy-tool .This produces the ./gitlab-copy-tool binary (no runtime dependency).
- A personal access token (scope
api) for source and target each. - For the SSH transport: matching SSH keys in your local SSH agent.
- For the optional container registry copy: tokens with registry access
(the
apiscope covers this). No external tool needed — the copy is pure Go.
./gitlab-copy-toolFlow in the UI:
- Choose a session or "+ New session".
- Connections: source URL + token, target URL + token, transport
(
auto/ssh/https). Test the connection withctrl+s. - Discovery: source structure + target namespaces are loaded.
- Mapping: select repos/groups with
space, set the target with←/→(cascades on a group), toggle options1–4. - Press
enteron a repo/group to pick its target (filter, free path, or a suggestion), toggle options1–6, thenctrl+pto save & run (ctrl+ssaves the config without running) → live progress, then a summary.
Tip: start with
--dry-runfirst (see below) and review the plan before anything is actually pushed.
Tokens may be entered as an environment reference, e.g. ${SRC_TOKEN}. Then
only the reference is stored in the session file, and the value is read from the
environment at runtime:
export SRC_TOKEN=glpat-… TGT_TOKEN=glpat-…
./gitlab-copy-tool # enter ${SRC_TOKEN} / ${TGT_TOKEN} in the formSession picker: ↑/↓ select · enter open · c clear state · d delete · q quit
Mapping:
| Key | Action |
|---|---|
↑/↓ |
Navigate |
space |
(De)select item (a group toggles all its children) |
enter / t |
Open the target picker for the highlighted node: type to filter or enter a free path; choose from existing target namespaces and source-derived suggestions (the repo's own path bottom-up, and that path under each existing account). ctrl+u clears the target |
←/→ |
Quick-cycle the target through the candidate list (on a group it cascades, on a repo only there) |
f |
Toggle force for this repo — deletes & recreates the target for a clean re-transfer (also fixes pushes rejected by a protected default branch) |
ctrl+a |
Archive all already-transferred repos on the source (reversible); an [archived] marker is shown behind them |
a / N |
Select all / none |
1–6 |
Cycle the highlighted node's option (inherit → on → off) — Issues/MRs, CI vars, Settings, URL rewrite, Releases, Container registry. On a group it cascades to the substructure |
ctrl+s |
Save the session config (no migration) |
ctrl+p |
Save and run the migration (or show the plan in dry-run) |
esc |
Back |
q quits from any screen — except the connection form (where you type values
that may contain q; there ctrl+c quits).
Shows the resolved plan (source → target per repo) but pushes nothing and changes nothing:
./gitlab-copy-tool --dry-run # interactive: pick/create a session, then plan
./gitlab-copy-tool run --dry-run # same
./gitlab-copy-tool run --session NAME --dry-run # non-interactiveIn dry-run mode, c clears a session only temporarily (nothing is written to
disk, shown as [temporarily cleared]).
Sessions live under ~/.config/gitlab-copy-tool/sessions/<name>.json (file mode
0600) and contain connections, selection, mapping, options and the path map —
but not plaintext tokens if you use ${ENV} references.
./gitlab-copy-tool sessions list
./gitlab-copy-tool sessions rm "<name>"
# run a saved session (e.g. in CI):
./gitlab-copy-tool run --session "<name>"
./gitlab-copy-tool run --session "<name>" --path-map extra.jsonIn the session picker, c clears the state (selection, target assignments,
force flags, path map) — URLs, tokens and options are kept. d deletes the
session entirely.
Before overwriting an already existing target repo, the tool compares the refs:
- Target has nothing newer → fully overwrite (force+prune push of branches/tags).
- Target has newer/divergent commits or its own branches → skipped with a reason (in the log and the summary).
- With
f(force) per repo, the tool deletes and recreates the target project for a clean re-transfer. This is also the reliable way to re-push when the target's default branch is protected (a force-push to a protected branch is rejected by GitLab; pushing into a fresh repo is not).
- Group projects are migrated; projects in the personal namespace (source and target) are excluded.
- Target namespaces = groups with at least Maintainer access.
- Issue/MR import copies title/description/labels (for MRs also source/target branch), but no comments, authors, discussions or numbers (best effort). Only open MRs are recreated.
- GitLab rule: project/subgroup ≤ parent group. A public repo under a private target account is therefore only possible once the account itself is visible enough — otherwise a warning.
- When setting the path, the URL rewrite only knows repos migrated in this session (earlier or in the same run). For a not-yet-migrated repo only the host + account prefix applies — migrate the dependency first or in the same run.
- Container registry copy is registry-to-registry in pure Go; it can move a lot of data. Package and model registries are not copied — they are usually rebuilt/republished by CI after migration.
- Session files use mode
0600. Tokens are stored in plaintext there unless you use${ENV_VAR}references (recommended for shared/committed setups). - The tool only talks to the two GitLab instances you specify.
- The source instance is read-only for the tool — nothing on the source is modified. Retiring old repos (archive/delete) is a manual step on your side.
main.go CLI (cobra): interactive, sessions, run
internal/
config/ session persistence + path map
gitlabapi/ REST API: discovery, groups/projects, metadata
gittransport/ git mirror clone/push, existing-target guard
rewrite/ host/path rewrite in composer.json & root files
migrate/ orchestration of a run
tui/ Bubble Tea UI (screens)
MIT.