Migrating from Mercurial to Git Jump to heading

Mercurial and Git have the same core model β€” a graph of commits identified by hashes β€” so converting history is mechanical, but the details differ enough to need care. Mercurial’s named branches are recorded in every commit, while Git branches are movable pointers; bookmarks are the closer equivalent. Mercurial authors are free-form strings that often lack an email. Tags live in a versioned .hgtags file rather than as refs. Subrepositories have no direct Git counterpart. A good migration maps all of these deliberately, verifies that every converted commit has the same file contents as the original, and switches the team over on a planned date. This page does that with hg-fast-export, within repository migration and consolidation.

When to use this approach Jump to heading

Step 1 β€” Inventory the Mercurial repository Jump to heading

List authors, named branches, bookmarks, tags and subrepositories before converting, so each can be mapped deliberately.

cd legacy-hg
hg log --template '{author}\n' | sort | uniq -c | sort -rn > ../authors-raw.txt
hg branches --closed
hg bookmarks
hg tags | head
[ -f .hgsub ] && cat .hgsub
hg log -r 'head() and not closed()' --template '{node|short} {branch}\n'     # multiple heads per branch?
How Mercurial concepts map to GitMercurial named branches become Git branches, though Git does not record the branch name in each commit. Bookmarks map directly to Git branches. Tags in the .hgtags file become Git tags. Free-form authors need mapping to name and email. Subrepositories become submodules or are merged into the tree.MercurialGitnamed branchin every commitbranch refbookmarkmovable pointerbranch reftagline in .hgtagstag refauthorfree-form stringname + emailsubrepositoryin .hgsubsubmodule or merged indecide each mapping before the first conversion run

Step 2 β€” Write an author map Jump to heading

Map every Mercurial author string to a Git name and email. Unmapped authors without emails produce commits that forges cannot link to accounts.

awk '{ $1=""; sub(/^ /,""); print }' ../authors-raw.txt > ../authors-list.txt
# Edit into the format hg-fast-export expects: "hg author"="Git Name <email>"
cat > ../authors.map <<'EOF'
"jdoe"="Jane Doe <[email protected]>"
"Jane Doe <[email protected]>"="Jane Doe <[email protected]>"
"build bot"="Build Bot <[email protected]>"
EOF

Author rewriting in general is covered in rewriting author emails during a migration.

Step 3 β€” Convert with hg-fast-export Jump to heading

Create an empty Git repository and run the converter against the Mercurial repository. It converts named branches to Git branches, default to the branch you choose, and .hgtags entries to tags.

git init converted && cd converted
git config core.ignoreCase false
/opt/fast-export/hg-fast-export.sh -r ../legacy-hg -A ../authors.map -M main
git branch -a
git tag | head

If the repository has several heads on one named branch, the converter stops and asks you to resolve them; merge or close the extra heads in Mercurial, or pass the option to name them separately.

Step 4 β€” Handle bookmarks and closed branches Jump to heading

Bookmarks are converted as branches when supported by the converter version; check. Closed Mercurial branches still become Git branches β€” delete them in Git or keep them under an archive prefix.

git for-each-ref --format='%(refname:short)' refs/heads/ | while read -r b; do
  hg -R ../legacy-hg log -r "branch('$b') and closed()" -l 1 --template x 2>/dev/null | grep -q x && echo "closed: $b"
done
git branch -m old-feature archive/old-feature        # keep, but out of the way
What to do with each converted branchActive named branches and bookmarks stay as Git branches. Closed branches are archived under a prefix or deleted after confirming they are merged. Branches with multiple heads must be resolved in Mercurial before the final conversion.What was the branch in Mercurial?active branch / bookmarkKeepnormal Git branchclosed branchArchive or deletearchive/ prefixseveral headsResolve firstmerge or close in hgresolve multiple heads before the final run, not after

Step 5 β€” Convert subrepositories Jump to heading

Subrepositories can become Git submodules, each converted separately, or be merged into the main tree if they were never really independent. Converting them to submodules preserves the boundary; merging simplifies day-to-day work.

# Option A: convert each subrepo separately, then add as a submodule at the recorded revision
/opt/fast-export/hg-fast-export.sh -r ../legacy-hg/libs/parser -A ../authors.map -M main   # in its own empty repo
git submodule add https://git.example.com/org/parser.git libs/parser

Step 6 β€” Verify contents match Jump to heading

Compare the file tree at every tag and branch head in both systems. Identical trees prove the conversion preserved content; history shape is checked by commit counts.

for t in $(git tag); do
  hg -R ../legacy-hg archive -r "$t" -t files "/tmp/hg-$t" >/dev/null 2>&1
  rm -f "/tmp/hg-$t/.hg_archival.txt" "/tmp/hg-$t/.hgtags"
  mkdir -p "/tmp/git-$t" && git archive "$t" | tar -x -C "/tmp/git-$t"
  diff -r -q "/tmp/hg-$t" "/tmp/git-$t" >/dev/null && echo "ok   $t" || echo "DIFF $t"
done
echo "hg: $(hg -R ../legacy-hg log --template x | wc -c)  git: $(git rev-list --all --count)"

The .hgtags file appears in Mercurial trees but becomes refs in Git, so it is excluded from the comparison.

Step 7 β€” Cut over Jump to heading

Freeze the Mercurial repository, run a final incremental conversion, push to the Git host, and point everyone at the new remote. Keep Mercurial read-only for reference.

/opt/fast-export/hg-fast-export.sh -r ../legacy-hg -A ../authors.map -M main     # incremental: picks up new commits
git remote add origin https://git.example.com/org/app.git
git push origin --all && git push origin --tags
A Mercurial-to-Git migration timelineThe repository is inventoried and an author map written. Trial conversions run and are verified while development continues in Mercurial. On cutover day, Mercurial is frozen, a final incremental conversion runs, and the result is pushed. Mercurial stays read-only for reference afterwards.Inventoryauthors, branches, subreposweek 1Trial runsverify trees at tagsweek 2Freeze hgfinal incremental runcutoverPush to Gitteam switches remotecutoverhg read-onlykept for referenceaftertrial runs are cheap β€” do several before the real one

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Are commit hashes preserved? Jump to heading

No. Git computes different hashes from different object formats. Record the mapping the converter produces, so references to Mercurial hashes in issues can be translated.

What about large files tracked with largefiles? Jump to heading

Convert them to Git LFS during or after conversion, as in migrating large binaries to Git LFS.

Can we keep working in Mercurial during trial runs? Jump to heading

Yes. The converter is incremental, so each run picks up new commits, and the final run at cutover is short.