Preserving pull request history in a migration Jump to heading

Git history records what changed; pull request history records why. Review discussions, design decisions argued in comments, the reasons a change was approved or held back β€” all of that lives on the hosting platform, not in Git. A migration that pushes branches and tags moves the code and loses the reasoning. Months later, someone running git blame finds a merge commit that says β€œMerge pull request #2817” and a link to a host that no longer exists. Full fidelity migration between platforms is rarely possible, but most of the value can be kept: export the discussions, keep the pull request refs, publish an archive, and make every reference in history lead somewhere. This page does that, within repository migration and consolidation.

When to use this approach Jump to heading

  • A repository with valuable review history is moving to another host.
  • Commit messages and code comments reference pull request numbers.
  • You need an audit trail of approvals for compliance after the move.
  • You are also moving pipelines, as in migrating CI and hooks with a repository.

Step 1 β€” Decide what level of fidelity you need Jump to heading

Native import keeps pull requests as live objects on the new host, where an importer for the platform pair exists. An archive keeps them as read-only pages. Refs-only keeps the commits but not the discussion. Choose per repository.

Levels of pull request preservationA native import recreates pull requests on the new host with comments and reviews, but depends on an importer for the platform pair and on user mapping. A static archive keeps every discussion as read-only pages with stable links. Keeping refs only preserves the code each pull request contained, without the discussion.What survivesEffortnative importPRs, comments, reviewsimporter + user mappingstatic archivefull discussion, read-onlyexport + host pagesrefs onlycode of each PRone fetch + pushmany teams import open pull requests and archive closed ones

Step 2 β€” Export pull requests with comments and reviews Jump to heading

Export every pull request with its description, comments, review comments and review decisions through the API, while the old host is still available.

mkdir -p pr-export
gh pr list --state all --limit 10000 --json number --jq '.[].number' | while read -r n; do
  gh pr view "$n" --json number,title,body,author,createdAt,mergedAt,state,baseRefName,headRefName,mergeCommit,comments,reviews,url \
    > "pr-export/$n.json"
  gh api --paginate "repos/$OWNER/$REPO/pulls/$n/comments" > "pr-export/$n.review-comments.json"
done
ls pr-export | wc -l

Respect API rate limits on large repositories by pausing between batches; the export can run over several days before cutover and be refreshed at the end.

Step 3 β€” Keep the pull request refs Jump to heading

Hosts keep each pull request’s head under a special ref namespace. Fetch them and push them to the new host under a namespace of your own, so the exact commits of every pull request β€” including unmerged ones β€” remain available.

git fetch origin '+refs/pull/*/head:refs/pull-archive/*'
git for-each-ref refs/pull-archive/ | wc -l
git push new-origin 'refs/pull-archive/*:refs/pull-archive/*'

Pushes into some reserved namespaces are refused by hosts; a custom prefix such as refs/pull-archive/ avoids that.

Step 4 β€” Publish a static archive Jump to heading

Render the exported JSON into simple read-only pages β€” one per pull request, with title, description, comments and review decisions β€” and host them at a stable URL.

mkdir -p pr-archive
for f in pr-export/[0-9]*.json; do
  case "$f" in *review-comments*) continue ;; esac
  n=$(basename "$f" .json)
  jq -r '"# #\(.number) \(.title)\n\nAuthor: \(.author.login) Β· Created: \(.createdAt) Β· State: \(.state)\n\n\(.body // "")\n\n## Comments\n" +
         ([.comments[] | "**\(.author.login)** (\(.createdAt)):\n\n\(.body)\n"] | join("\n")) +
         "\n## Reviews\n" + ([.reviews[] | "- \(.author.login): \(.state)"] | join("\n"))' "$f" > "pr-archive/$n.md"
done
From old host to a permanent archivePull requests are exported through the API while the old host is still running. The export is rendered into read-only pages and published at a stable URL. Pull request refs are pushed to the new host. Finally, links in history and documentation are pointed at the archive.ExportAPI, before cutoverRenderone page per PRPublishstable URLKeep refsrefs/pull-archive/*Redirect linksold β†’ archivethe old host must still be running for the first step β€” start early

Merge commits say β€œMerge pull request #2817”. Make that number lead to the archive: add a note to each merge commit with the archive URL, and document the URL pattern in the repository.

git fetch new-origin 'refs/notes/*:refs/notes/*' 2>/dev/null || true
git log --first-parent --format='%H %s' main | sed -n 's/^\([0-9a-f]*\) Merge pull request #\([0-9]*\).*/\1 \2/p' |
while read -r sha n; do
  git notes --ref=pr append -m "Archived discussion: https://pr-archive.example.com/app/$n" "$sha"
done
git push new-origin refs/notes/pr

For squash-merged repositories, the pull request number is usually in the commit subject as (#2817); adjust the pattern.

Links to the old host’s pull request pages appear in issue trackers, chat, documentation and code comments. If you control the old domain, redirect pull request URLs to the archive. Otherwise, search and replace in the places you own.

git grep -n -E 'https://git\.old\.example/org/app/(pull|merge_requests)/[0-9]+' -- ':!pr-archive' | head
# Example redirect rule on the old domain (web server configuration):
#   /org/app/pull/(\d+)  β†’  https://pr-archive.example.com/app/$1

Step 7 β€” Recreate open pull requests Jump to heading

Open pull requests need to stay workable. Merge or close as many as possible before cutover; recreate the rest on the new host from their branches, linking to the archived discussion.

gh pr list --state open --json number,headRefName,title --jq '.[] | "\(.number)\t\(.headRefName)\t\(.title)"' |
while IFS="$(printf '\t')" read -r n branch title; do
  echo "recreate: $branch β€” $title (was #$n) β€” https://pr-archive.example.com/app/$n"
done
What happens to each pull request?A merged or closed pull request is exported, archived and linked from its merge commit. An open pull request that can be finished before cutover is merged or closed first. An open pull request that cannot is recreated on the new host with a link to its archived discussion.What state is the pull request in?merged / closedArchivenote on merge commitopen, nearly doneFinish firstbefore cutoveropen, long-runningRecreatelink to archiveannounce a cutover date early so authors can finish what they can

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Do imported pull requests keep their numbers? Jump to heading

Usually not: the new host assigns numbers in its own sequence, often shared with issues. That is why links in history should point to the archive, whose URLs use the original numbers.

What about authors who do not exist on the new host? Jump to heading

Archives show the original usernames as text. Native imports need a user mapping; unmapped users appear as a placeholder or the importing account.

Is the archive needed if we import natively? Jump to heading

It is cheap insurance. Imports sometimes drop review comments on outdated lines or attachments, and an archive keeps a faithful copy.