Migrating Repositories Between Providers

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

This page shows how to move a repository from one Git host to another — with every commit, branch and tag, plus its Git LFS objects — using nothing but Git itself. The worked example moves the fictitious Bitbucket Cloud repository acme-legacy/inventory-api to the fictitious GitHub organization acme-labs, but the same flow works between any two Git hosts. It targets Git 2.55.x, Git LFS 3.x, GitHub CLI (gh) 2.101.x, IntelliJ IDEA 2026.2, current stable VS Code, and github.com and bitbucket.org as of October 2026. Changing who owns a repository within GitHub is a different, simpler operation, covered in Transferring Repositories Between GitHub Accounts.

Concept

Normal, bare and mirror clones

A Git repository is its history plus its refs (see Repositories and Cloning). Moving it to another host means copying all the refs, not just the branch you happen to have checked out — and the three kinds of clone differ exactly in which refs they copy:

Clone What it copies Good for

git clone <url>

Every commit reachable from the remote’s branches and tags, but the remote’s branches land as remote-tracking branches (origin/...), and only the default branch gets a local branch. A working tree is checked out.

Day-to-day work.

git clone --bare <url>

No working tree; the remote’s branches are copied directly to local branches, and tags are copied. Other refs (notes, remote-tracking branches on the source, host-specific refs) are not.

A one-off copy of branches and tags (this is what GitHub’s own Duplicating a repository guide uses).

git clone --mirror <url>

Implies --bare, and copies every ref under refs/ — branches, tags, notes, and anything else the source advertises — and configures the clone so that a later git remote update overwrites them all again.

A complete migration, or a mirror kept in sync over time.

Derived from git-clone (--bare, --mirror).

What a Git migration does not move

git push --mirror moves everything that is Git: commits, branches, tags and other refs. Everything a hosting provider stores next to the Git data stays behind and must be recreated or migrated separately:

  • pull requests and their review comments, and issues;

  • wikis (on both Bitbucket Cloud and GitHub a wiki is a separate Git repository, which you can mirror the same way if you need it);

  • CI/CD configuration that lives outside the repository (Bitbucket Pipelines variables, deployment environments);

  • branch protection / branch restrictions, merge checks and default reviewers;

  • users, groups, teams and permissions;

  • webhooks, deploy keys, repository variables and secrets;

  • Git LFS objects — these live in a separate store and need their own fetch and push (see Git LFS and large files).

GitHub also offers tools that move some of this metadata. GitHub Enterprise Importer migrates repositories including pull requests and other metadata into GitHub Enterprise Cloud, but from Bitbucket it supports only Bitbucket Server and Data Center, not Bitbucket Cloud. GitHub Importer (Import repository on github.com) imports a Git repository from a public URL, but only the code and its history — no issues or pull requests — and it does not move Git LFS objects; the REST API that used to drive it (Source imports) has been retired. The manual flow on this page works with any host, private networks included, and is the one to understand even when you use a tool. See About GitHub Enterprise Importer and About GitHub Importer.

flowchart LR SRC["Source host: bitbucket.org/acme-legacy/inventory-api"] -->|"git clone --mirror"| MIR["Local bare mirror: inventory-api.git"] SRC -->|"git lfs fetch --all"| LFS["Local LFS object store"] MIR -->|"git push --mirror"| DST["Empty target: github.com/acme-labs/inventory-api"] LFS -->|"git lfs push --all"| DST

The local mirror is only a transit copy: once the target is verified, it is deleted and every developer re-points their existing clone at the new host.

Command line

The steps below move acme-legacy/inventory-api from Bitbucket Cloud to acme-labs/inventory-api on GitHub. Replace the workspace, organization and repository names with yours.

1. Create the empty target repository

gh repo create acme-labs/inventory-api --private --description "Inventory API (migrated from Bitbucket)"

Create it empty: no README, .gitignore or license, otherwise the first git push --mirror would have to overwrite GitHub’s initial commit. Derived from gh repo create and Duplicating a repository.

2. Get a read credential for the source

Bitbucket Cloud has retired app passwords in favour of API tokens (personal, tied to your Atlassian account) and access tokens (repository, project or workspace level, tied to that resource). For a one-off migration, a repository access token with the Repositories: Read permission (plus Pull requests: Read if you will export pull requests separately) is the narrowest choice; create it as described in On Bitbucket, copy it once (Bitbucket never shows it again), and give it a short expiry.

With an access token, Git over HTTPS uses the literal username x-token-auth and the token as the password. Put the username in the URL, never the token: a token in the URL ends up in your shell history and in the clone’s config file. See Using access tokens for a repository and the repository access token permissions. If you authenticate to Bitbucket with SSH keys or Git Credential Manager already, you can use that instead and skip the token.

3. Keep credentials per repository

git config --global credential.https://bitbucket.org.useHttpPath true

By default a credential helper stores one credential per host, so a repository access token cached for inventory-api would be offered for every other bitbucket.org repository too (and an older cached app password would be offered for this one). With useHttpPath set for bitbucket.org only, the helper keys credentials by the full repository path. Derived from gitcredentials (credential.useHttpPath and credential.<url>.*).

4. Mirror-clone the source

git clone --mirror https://x-token-auth@bitbucket.org/acme-legacy/inventory-api.git
# Password prompt: paste <BITBUCKET_REPOSITORY_ACCESS_TOKEN>
cd inventory-api.git

The result is a bare repository directory, inventory-api.git, holding every ref the source advertises. Derived from git-clone.

5. Fetch the Git LFS objects (only if the repository uses LFS)

git lfs ls-files --all | head     # any output means the history references LFS objects
git lfs fetch --all               # download every LFS object referenced by any ref

A mirror clone copies the LFS pointer files but not the objects they point to. Details, and what to do with files that are too large for GitHub, are in Git LFS and large files.

6. Push everything to the target

git push --mirror https://github.com/acme-labs/inventory-api.git
git lfs push --all https://github.com/acme-labs/inventory-api.git   # only if step 5 applied

git push --mirror pushes every ref under refs/: new refs are created, changed refs are force-updated and refs that exist on the target but not locally are deleted. Against an empty target that is exactly what you want; against a repository that already has content, it destroys whatever the mirror does not contain. This is the same sequence as GitHub’s Duplicating a repository guide. If the push is rejected with deny updating a hidden ref, see Common mistakes / tips. Derived from git-push (--mirror) and Duplicating a repository.

7. Verify

git ls-remote --heads https://x-token-auth@bitbucket.org/acme-legacy/inventory-api.git | sort > /tmp/src-heads
git ls-remote --heads https://github.com/acme-labs/inventory-api.git | sort > /tmp/dst-heads
diff /tmp/src-heads /tmp/dst-heads && echo "branches match"

git ls-remote --tags https://x-token-auth@bitbucket.org/acme-legacy/inventory-api.git | sort > /tmp/src-tags
git ls-remote --tags https://github.com/acme-labs/inventory-api.git | sort > /tmp/dst-tags
diff /tmp/src-tags /tmp/dst-tags && echo "tags match"

gh repo view acme-labs/inventory-api --json defaultBranchRef --jq .defaultBranchRef.name

Branch and tag names and the commit IDs they point to must match; commit IDs only change if history was rewritten (for example by git lfs migrate import). Check that the GitHub default branch is the one the team used on Bitbucket, and, for LFS, clone the new repository into a scratch directory and run git lfs fsck there. Derived from git-ls-remote.

8. Clean up the transit copy

cd ..
rm -rf inventory-api.git

9. Re-point existing working copies

Each developer keeps their clone, local branches and stashes, and only changes the URL of origin:

git remote set-url origin https://github.com/acme-labs/inventory-api.git   # or git@github.com:acme-labs/inventory-api.git
git remote -v
git fetch --prune origin
git branch -u origin/main main     # only if the default branch name changed

Derived from git-remote.

10. Finish the move

  • Recreate branch protection (or rulesets), required reviews and status checks on GitHub.

  • Port CI: Bitbucket Pipelines (bitbucket-pipelines.yml) does not run on GitHub; rewrite it as a GitHub Actions workflow and recreate its variables as Actions secrets and variables.

  • Recreate webhooks, deploy keys and integrations, and update anything that hard-codes the old URL (build scripts, submodule URLs in .gitmodules, package manifests, badges in the README).

  • Archive the Bitbucket repository, or remove write access to it, so nobody keeps pushing to the old location.

  • Revoke the repository access token.

Git LFS and large files

Git LFS replaces large files in the Git history with small text pointer files and stores the real content in a separate LFS store on the host. Which paths are stored that way is recorded in .gitattributes:

git lfs install                  # once per machine: installs the LFS filters and hooks
git lfs track "*.psd"            # appends a rule to .gitattributes
git add .gitattributes
git lfs ls-files                 # LFS files in the current commit

Because the content is outside Git, git clone --mirror and git push --mirror move only the pointers. A migration therefore needs the order shown in the command-line steps: git lfs fetch --all (download every LFS object referenced by any ref) → git push --mirror → git lfs push --all <target-url> (upload them all). Check the result with git lfs ls-files --all on the mirror and with git lfs fsck in a fresh clone of the target. If the target host rejects the ref push because the LFS objects it references are not there yet, run git lfs push --all <target-url> first and the mirror push second. See git-lfs.com, the Git LFS man pages and GitHub’s About Git Large File Storage.

Files over GitHub’s limit that are not in LFS

Bitbucket and GitHub limit large files differently. GitHub warns about any file larger than 50 MiB in a push and blocks any file larger than 100 MiB, anywhere in the history being pushed — so an old, long-deleted 120 MiB archive still in a commit on some branch makes git push --mirror fail. Find such files first, in the mirror:

git lfs migrate info --everything --above=50MB --top=20

Then convert them to LFS, rewriting the whole history:

git lfs migrate import --everything --include="*.zip,*.psd"   # by pattern
git lfs migrate import --everything --above=100MB             # or by size (cannot be combined with --include)

--everything rewrites every local ref, which in a mirror clone is every ref. Two consequences:

  • Every commit SHA from the first converted file onwards changes. Links to commits, signed commits and tags, and every existing clone stop matching. Collaborators must re-clone the new repository instead of re-pointing their old clone, or they will push the old, unconverted history back.

  • The converted files now count towards GitHub’s Git LFS storage and bandwidth, which are billed per account; the per-file maximum for LFS also depends on the plan. Check the current figures in About large files on GitHub and About billing for Git Large File Storage rather than relying on a number copied here.

On GitHub.com

Preparing the target

  1. Click + in the top-right corner and choose New repository.

  2. Choose the owner (acme-labs) and the name (inventory-api) and the visibility, and leave Add a README file, .gitignore template and license empty.

  3. Click Create repository. Ignore the "push an existing repository" snippet GitHub shows; use the mirror push instead.

After the push

  1. On the repository’s main page, open the branch dropdown and the Tags view to spot-check that every branch and tag is there (the command-line comparison in step 7 is the complete check).

  2. Under Settings, set the Default branch if it is not the one the team used.

  3. Recreate branch protection rules or rulesets under Settings, and the Actions secrets and variables under Settings → Secrets and variables → Actions.

GitHub’s settings pages are rearranged from time to time; if a label differs, follow Configuring branches and merges in your repository and Using secrets in GitHub Actions.

On Bitbucket

Creating a repository access token

  1. Open the repository on bitbucket.org (acme-legacy/inventory-api).

  2. In the left sidebar, next to the repository name, open the …​ menu and choose Settings.

  3. Under Security, choose Access tokens, then Create access token.

  4. Give it a name (for example github-migration), an expiry date a few days away, and the Repositories: Read permission only (add Pull requests: Read if you will export pull requests separately).

  5. Click Create and copy the token immediately — it is shown only once.

Derived from Create a repository access token. A personal API token (created from your Atlassian account settings) works too but reaches every repository your account can see, so prefer the repository token. App passwords, which older guides use for this step, are retired; see Bitbucket Cloud transitions to API tokens.

Retiring the old repository

Once the team has switched, remove write permissions on the Bitbucket repository (Settings → Repository permissions) or delete it after an agreed grace period, and revoke the access token on the same Access tokens page.

In IntelliJ IDEA

The migration itself is a command-line job; inside the IDE you only re-point an existing project:

  1. Choose Git | Manage Remotes.

  2. Select origin, click the edit (pencil) icon, replace the Bitbucket URL with https://github.com/acme-labs/inventory-api.git (or the SSH form), and click OK.

  3. Run Git | Fetch to load the branches from the new host.

Derived from JetBrains — Set up a Git repository (Add a remote repository).

In VS Code

VS Code has no "edit remote" command, so re-point origin by removing it and adding it again:

  1. Open the Command Palette (Ctrl+Shift+P / Cmd+Shift+P) and run Git: Remove Remote, choosing origin.

  2. Run Git: Add Remote, paste https://github.com/acme-labs/inventory-api.git, and name it origin.

  3. Run Git: Fetch, then set the upstream of your local branches on the next push (VS Code offers Publish Branch or sets it automatically).

Alternatively run git remote set-url origin <new-url> in the integrated terminal, which keeps the upstream settings. Derived from VS Code — Source Control.

Common mistakes / tips

  • Putting the token in the clone URL. https://x-token-auth:<token>@... works, but the token then sits in shell history and in the mirror’s config. Use the username-only URL and let Git prompt, or a credential helper.

  • Mirror-pushing into a non-empty repository. git push --mirror deletes every target ref the local mirror does not have, including a README commit made at creation time or branches someone already pushed. Always push into an empty repository, and double-check the URL.

  • Hidden refs rejected (deny updating a hidden ref). A mirror clone may contain host-specific refs, such as pull-request refs (refs/pull-requests/* from some Bitbucket versions, or refs/pull/* when the source is a GitHub repository). The target host reserves its own pull-request namespace and rejects the push. Delete those refs from the mirror before pushing, or push only branches and tags instead of --mirror:

    git for-each-ref --format="%(refname)" refs/pull refs/pull-requests | xargs -r -n 1 git update-ref -d
    # or, instead of --mirror:
    git push https://github.com/acme-labs/inventory-api.git "refs/heads/*:refs/heads/*" "refs/tags/*:refs/tags/*"
  • Forgetting LFS. Without git lfs fetch --all and git lfs push --all, the new repository contains only pointer files, and every checkout of an LFS path fails with a missing-object error. Verify in a fresh clone.

  • Rewriting history without telling anyone. After git lfs migrate import, old clones contain the old commits; one git push --force from such a clone puts the large files back. Announce the cut-over and have everyone re-clone.

  • Leaving the token alive. Revoke the repository access token when the migration is verified, and remove the useHttpPath setting if you do not need it any more (git config --global --unset credential.https://bitbucket.org.usehttppath).

For moving a repository between GitHub accounts or organizations, where no Git copy is needed at all, see Transferring Repositories Between GitHub Accounts.