Skip to content

Migrating a CodeCommit repo to GitHub — refs, LFS, hooks, and the cutover redirect

A clean CodeCommit-to-GitHub migration moves all refs, LFS objects, and hook contracts. Here is the mirror-push pattern, the validation checklist, and the redirect for stragglers.

The decision is made — the repo moves from CodeCommit to GitHub. The naive plan is git clone from CodeCommit, git remote add to GitHub, git push. That gets the default branch and tags across. It misses every other ref the team has built up: feature branches in flight, LFS objects, the CodePipeline trigger that fires on every push, the four developers whose IDEs are still configured against the old origin, the CI scripts that hard-code the CodeCommit URL. A clean cutover handles all of them. A rushed cutover leaves orphan branches in CodeCommit, a build pipeline that suddenly fails because the source has moved, and three weeks of “wait, where’s my branch?” Slack messages.

What’s happening

A Git repository is a graph of objects (commits, trees, blobs) plus a set of named references (branches, tags, notes, sometimes custom refs). A git clone by default copies only the default branch’s history plus tags. A git clone --mirror copies every ref, including remote-tracking branches and notes — that’s the right starting point for a migration.

LFS objects live outside the Git object store. They’re tracked by pointer files in the repo and stored separately (CodeCommit’s LFS endpoint, in this case). Pushing the Git refs to GitHub without explicitly pushing LFS objects leaves you with a repo full of broken pointers. git lfs fetch --all followed by git lfs push --all is the LFS leg of the migration.

CodeCommit’s “hooks” aren’t Git hooks in the traditional sense — there are no pre-receive scripts inside the repo. The equivalent is a RepositoryTrigger: a configured action that fires on push events to an SNS topic, Lambda, or CodePipeline. These don’t migrate automatically. The team’s CI is driven by these triggers; on GitHub the equivalent is a webhook, a GitHub App, or an Actions workflow on the push event. Mapping the triggers to GitHub equivalents is the second half of the migration.

The redirect for stragglers is the part teams forget. After cutover, the old CodeCommit URL still exists. Developers who haven’t updated their remotes will continue to push to CodeCommit and lose their work when it eventually goes away. The cutover plan needs a way to make the old endpoint fail loudly (not silently) so the affected developer is forced to update.

How to verify

Inventory the repo before touching it:

# Get a clean mirror; all refs, no working tree
git clone --mirror codecommit::us-east-1://my-repo /tmp/my-repo-mirror
cd /tmp/my-repo-mirror

# Refs: how many branches, tags, notes, custom refs?
git for-each-ref --format='%(refname:short)' | wc -l
git for-each-ref --format='%(refname)' | awk -F/ '{print $2}' | sort | uniq -c

# Size of objects
du -sh objects/
git count-objects -v

# LFS inventory
git lfs ls-files --all | wc -l
git lfs ls-files --all --size | head -20

The first command’s count is the total ref count — multi-hundred is normal for a long-lived repo. The second tells you the structure (heads, tags, remotes). The third tells you whether the size is going to fit GitHub’s quotas without enabling LFS or asking for an exemption (GitHub’s hard limit is 5 GB per repo; recommended is under 1 GB). The fourth tells you whether LFS is in play at all.

Inventory the active triggers:

aws codecommit get-repository-triggers --repository-name my-repo \
  --query 'triggers[].{Name:name,Destination:destinationArn,Events:events}' \
  --output table

Each trigger row is a migration item: a Lambda, an SNS topic, or a CodePipeline that fires on push. Each needs a GitHub-side equivalent before the cutover, or a deliberate decision to retire.

Identify CodePipeline pipelines wired to the repo:

aws codepipeline list-pipelines --query 'pipelines[].name' --output text | \
  while read p; do
    src=$(aws codepipeline get-pipeline --name "$p" \
      --query 'pipeline.stages[?name==`Source`].actions[].configuration' \
      --output json)
    echo "$p: $src" | grep -i my-repo && echo "  ^^ uses my-repo"
  done

Anything that prints ^^ uses my-repo needs to be reconfigured against the GitHub source (via the AWS CodeStar Connections or GitHub source action) before the old triggers go dark.

The fix

The cutover sequence, in order:

  1. Pre-flight on the GitHub side. Create the target repo on GitHub. Configure branch protection (the CodeCommit IAM model for protected refs translates to GitHub branch protection rules — required reviewers, required status checks, no force-push to main). Add the team and their squad-equivalent GitHub team. Add an OIDC trust to your AWS account for Actions to assume roles without long-lived keys.

  2. Freeze writes to CodeCommit. Communicate a cutover window to the team. Optionally, attach a deny policy that blocks codecommit:GitPush on the repo for the duration:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "FreezeCodeCommitForMigration",
    "Effect": "Deny",
    "Action": "codecommit:GitPush",
    "Resource": "arn:aws:codecommit:us-east-1:123456789012:my-repo"
  }]
}

Attach to the engineering group during the window; detach after. This prevents racing pushes during the mirror.

  1. Mirror-clone and push. From the mirror created during the inventory step:
cd /tmp/my-repo-mirror

# Re-fetch in case of changes during freeze setup
git fetch --all --prune
git fetch --tags

# LFS objects first
git lfs fetch --all

# Add the GitHub remote
git remote add github git@github.com:my-org/my-repo.git

# Push all refs
git push github --mirror

# Push LFS objects
git lfs push --all github

git push --mirror is the right flag — it pushes all refs exactly as they are on the source, including deletions. The --mirror is destructive on the destination: if the GitHub repo is non-empty (e.g. from a “create with README” mistake), the push will overwrite. Create the GitHub repo empty.

  1. Validate post-push. Compare ref counts and a sample of commit SHAs:
# On the source mirror
git for-each-ref --format='%(refname) %(objectname)' | sort > /tmp/source-refs

# Clone the GitHub repo fresh
git clone --mirror git@github.com:my-org/my-repo.git /tmp/dest-mirror
cd /tmp/dest-mirror
git for-each-ref --format='%(refname) %(objectname)' | sort > /tmp/dest-refs

diff /tmp/source-refs /tmp/dest-refs

The diff should be empty. If LFS is in use, also validate that pointer files resolve:

# In the destination clone (not mirror)
git clone git@github.com:my-org/my-repo.git /tmp/dest-clone
cd /tmp/dest-clone
git lfs pull
# Spot-check a known LFS file
file path/to/large.bin   # should report the real type, not "ASCII text" of a pointer
  1. Reconfigure CI and triggers. For each entry from the trigger inventory:

    • CodePipeline source actions: swap from CodeCommit source to a CodeStar Connection or GitHub source pointing at the new repo. The IAM role on the pipeline needs codestar-connections:UseConnection instead of codecommit:GitPull. Use a CodeBuild project to run the build the same way as before, or migrate the build to GitHub Actions.
    • Lambda triggers: rewrite as GitHub webhook handlers (API Gateway → Lambda, with HMAC verification using the GitHub webhook secret) or as Actions workflows that call the Lambda via aws lambda invoke. The Actions-based version is cleaner and removes the API Gateway from the path.
    • SNS notifications: GitHub doesn’t natively publish to SNS. Replace with a workflow that uses aws-actions/configure-aws-credentials and aws sns publish from a step.
  2. Cut DNS, IDE config, and docs. Update README.md, the developer onboarding doc, any IDE .iml files committed to the repo, and any internal docs that reference the CodeCommit URL. Update the team’s central docs portal. Send an email and a Slack message with the new clone URL.

  3. Make the old CodeCommit URL fail loudly. This is the part that prevents drift. Two patterns:

    • Hard delete. Once the migration is validated and a grace period has passed (we use 30 days), delete the CodeCommit repo. The old URL returns a clear error. Run after the validation steps, not before.
    • Soft block. Keep the CodeCommit repo as a read-only archive. Attach a deny policy on codecommit:GitPush for everyone except a break-glass role. Developers attempting to push get a permission-denied error that surfaces in their terminal — they then update the remote.

    The soft block is the safer default. Convert to hard delete after a quarter with no incidents.

  4. Run a single-developer smoke push. Before broadcasting the migration as done, have one developer push a trivial commit to a feature branch on the new GitHub repo. Verify the Actions workflow fires, the CI runs, the AWS resources (if any) get the right IAM via OIDC. End-to-end on a single developer surfaces problems before the rest of the team hits them.

Two rules we hold:

Cutover within a single working day, not over a weekend. A weekend cutover means problems surface Monday morning at the worst possible time. A Tuesday-morning cutover gives the team the rest of the week to handle stragglers.

Keep the source mirror around for 90 days. The local /tmp/my-repo-mirror is the ground truth of what was on CodeCommit at cutover time. If a customer claims a missing branch, the mirror is the arbiter. Move it from /tmp to durable storage before it ages out.

The whole sequence is half a day of active work for a single repo, plus a quarter of grace period before the old origin goes away. For a fleet of repos, parallelize step 3 (mirror-push is independent per repo), but sequence the CI reconfiguration — that’s where the migration debt lives.


Stack Harbor runs CodePipeline + CodeCommit (and the GitHub-on-AWS variants) as part of Managed AWS onboarding and ongoing Managed Cloud Ops. If you have a fleet of CodeCommit repos to migrate before the deprecation horizon tightens, start with an infrastructure consult.