Git Subtree and Other Tips for Migrating a Repo

During a recent integration of a repo that housed a web app into an existing monorepo, my software development team ran into this killer tool: Git subtree. This tool, plus a few other tips, helped us migrate the repo in a way that minimized the impact on feature work and allowed us to maintain the most familiar development environment for the team.

The Situation

For context, we have a monorepo that is home to two mobile apps, and several web apps. The web apps are built with Next.js, while the mobile apps are built with React Native, and managed with Expo. The monorepo is managed with pnpm, specifically using pnpm workspaces.

The goal of this move was to migrate a web app whose mobile counterpart already existed in the monorepo, so that we could eventually introduce some shared code between the two.

Git Subtree

We found Git Subtree after searching for a way to maintain the git history of the external project. Specifically, we used the command: git subtree add –prefix=apps/<app-name> <remote-or-local-path> <branch-name>, where <app-name> is the name of the app in the monorepo, <remote-or-local-path> is the path to the external project, and <branch-name> is the branch of the external project to merge.

For those who aren’t familiar with Git Subtree, it works like this: the external project’s files are committed under a directory in the receiving repository (the monorepo), and they become part of its tree and history. Git Subtree can be run with or without –squash. Without –squash, the monorepo’s commit history includes the upstream project’s original commits, whose changes are scoped to the –prefix path; with –-squash, it records a single merge commit containing the upstream snapshot instead. The subtree merge commit(s) include markers (e.g., git-subtree-dir: <prefix>, git-subtree-split: <upstream-commit>) that allow future git subtree pull/push to find the correct base (if that’s necessary for your situation).

Note: This wasn’t the first time we’d moved a repo into this monorepo. Prior to this move, we’d moved a mobile app into the monorepo with a basic copy and paste. The problem with that approach? No previous history of the external project.

Git Subtree did a lot more for us than simply move some files around. It preserved the git history of the external project, which allowed us to maintain accountability for the previous changes. This is especially important when you have multiple teams working on the same codebase, and you need to be able to trace the changes back to the original source.

Using pnpm Things

Yeeting Generated Files

One of the next steps after migrating the repo is to remove the newly migrated app’s pnpm lockfile, package-lock.json, and node_modules folder.

Because we’re using a pnpm workspace, there’s a single lockfile at the root that controls all dependency versions. Any old lockfiles from the imported repo (like package-lock.json or a nested pnpm-lock.yaml) will conflict with this setup—they specify different versions and expect a different node_modules structure than what pnpm generates. You’ll need to remove them.

Removing them avoids stale artifacts and hoisting/layout mismatches that can hide peer-dependency issues. Running pnpm -w install at the workspace root recomputes versions across all packages, links workspace:* dependencies correctly, and writes the single authoritative lockfile before you build.

Contending with Version skew

Running pnpm -w install && pnpm build still produced errors. We hit a peer-dependency mismatch: the incoming web app targeted React 19 while existing apps were on React 18. So, we downgraded React to 18 in the newly migrated repo, and then still hit a peer-dependency mismatch with @react-aria/utils.

After some research, we found that certain @react-aria/utils releases publish types that assume React 19, and the workspace resolver selected one of those, surfacing peer/type errors. So, we added a root-level override to force @react-aria/[email protected]—a React 18‑compatible version—which deduped the library and satisfied peers.

Helpful Commands

These pnpm commands were instrumental in helping us diagnose and resolve the version skew issues:

pnpm view <package> versions to see available versions of a package in the registry and when you need to find a compatible version to override to.
pnpm why <package> to see where a package is coming from. Pinpoints which project or dependency pulled a package into the graph, helping you chase unexpected versions.
pnpm ls to list the dependencies. Helps to confirm whether a package is installed (and how), which is handy when a build complains something is missing.
pnpm list –depth Infinity outputs the entire dependency graph. Exposes the full dependency tree so you can spot duplicated or incompatible versions causing runtime issues.
pnpm list –depth Infinity –json outputs the entire dependency graph in JSON format. Gives the same deep tree in JSON, making it easier to script checks for conflicts or visualize the graph.

package.json Cleanup

One thing this move unearthed was a lot of unnecessary dependencies in the package.json files, or, in particular, duplicate dependencies in both “peerDependencies” and “devDependencies”.

"devDependencies": {
"react": "catalog:react18",
"react-dom": "catalog:react18"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},

Some of our shared packages were pulling in React and React DOM as devDependencies, which was causing duplicate Reacts in the bundle. The mismatch happens because package A was built and typed against one React (from its devDependencies), while app B provides a different React via its install. That yields peer-dependency warnings of incompatible .d.ts types (e.g., React 19 vs 18).

Moving react/react-dom to peerDependencies made the consumer (app B) the single source of truth, so our shared package A links against the exact React app B provides, aligning both runtime and type versions and eliminating duplicates.

If sharing a package A needed React to build locally, we could have kept it in devDependencies but matched the supported peer range (e.g., React 18 if apps are on 18).

Final Thoughts

This approach worked well for us for many reasons. We were able to lift and shift the repo quickly and get it to a working state, which meant less impact on multiple teams working on the same codebase. It also helped us preserve accountability, share code between web and mobile apps, and understand the versioning constraints we’ve placed on ourselves with that shared code.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *