Migrating 40+ Services to GitHub in One Script
How we untangled a monorepo of local-path submodules, orphan directories, and detached git repos — and pushed everything to GitHub with a single bash script.

Forty-seven threads, one knot. Before the migration, every service was tethered to a single machine by invisible local paths. After: each one anchored to its own point in the sky.
The Dr. Ray automation suite has 40+ services. Lambda functions, React apps, Playwright scrapers, MCP servers. They all live under one parent directory — ~/drray — stitched together with git submodules.
The problem: almost none of them were actually backed by GitHub.
The Mess
Over months of rapid development, the repo had accumulated three distinct flavors of source control debt:
35 submodules pointing to local filesystem paths. When I first set up the monorepo, I created bare repos in ~/drray-repos/ and added them as submodules. The .gitmodules file was full of entries like:
[submodule "docs-service"]
path = docs-service
url = /Users/mattdennis/drray-repos/docs-service
This worked fine on my machine. It would work on exactly zero other machines.
8 services with no git tracking at all. These were directories I’d created directly inside the parent repo — quick Lambda functions or utilities that never got their own git history. client-search, create-checkout-session, getSRFaxStatus — just folders with code, tracked by the parent repo’s git but with no independent history.
4 repos with .git directories that weren’t registered as submodules. These had been initialized with git init at some point, some even pushed to GitHub, but the parent repo didn’t know about them. They existed in a limbo — versioned locally but invisible to git submodule status.
A handful of services were already on GitHub with proper remotes — the newer Amplify apps like document-generator, rabbitsign-app, appointment-booking. But they were the exception. The majority of the codebase was one laptop failure away from being unrecoverable.
The Fix: One Script
Rather than migrating services one at a time — which would have meant 40+ rounds of gh repo create, git remote set-url, git push, and .gitmodules edits — we wrote a single bash script that handled everything: migrate-to-github.sh.
The script has three phases:
Phase 1: Submodules with local remotes. For each of the 35 local-path submodules, the script checks if a GitHub repo already exists under the grqg-dev org. If not, it creates one (private). Then it updates the submodule’s origin remote to point to GitHub, pushes the code, and rewrites the .gitmodules entry. A name-mapping table handles cases where the GitHub repo name differs from the directory name — fax-app-new maps to fax-viewer-app, mn-automation maps to mn-upload.
Phase 2: Plain directories. For the 8 untracked directories, the script initializes git, creates a .gitignore if one doesn’t exist, commits everything, creates a private GitHub repo, and pushes. Simple.
Phase 3: Detached git repos. For the 4 directories with .git but no submodule registration, the script verifies the remote is set correctly and pushes any unpushed commits. Only one of these (stripe-analysis) needed a new GitHub repo.
After all three phases, a final git submodule sync propagates the .gitmodules changes so that future git submodule update commands pull from GitHub instead of local paths.
The Details That Mattered
A few things that would have tripped up a naive approach:
Submodules use .git files, not directories. A git submodule’s .git is a text file containing gitdir: ../.git/modules/submodule-name, not an actual .git/ directory. Our initial check — [[ -d "$path/.git" ]] — failed for every single submodule. The fix: [[ -d "$path/.git" || -f "$path/.git" ]].
macOS ships bash 3.2. Associative arrays (declare -A) require bash 4+. Apple ships bash 3.2 from 2007 due to GPLv3 licensing. We replaced the associative array with a case statement for the name mapping. The kind of thing that works perfectly in CI and explodes on a Mac.
Some submodules already had GitHub remotes but stale .gitmodules. Services like clarius-scraper and heidi-scraper had been manually pushed to GitHub at some point, but .gitmodules still pointed to the local path. The script detects this case — if origin already points to github.com, it skips the remote update and just fixes .gitmodules.
Dry run mode was essential. The --dry-run flag made the script print every action without executing it. We caught the .git file issue, the bash 3.2 issue, and several name-mapping edge cases before any repos were created.
The Result
One execution. Every service in the Dr. Ray suite is now backed by a private GitHub repo under the grqg-dev organization. The .gitmodules file contains zero local filesystem paths. A git clone --recurse-submodules on a fresh machine would actually work.
The migration covered:
- 30 submodules — new GitHub repos created, remotes updated,
.gitmodulesrewritten - 5 submodules — already on GitHub, just needed
.gitmodulescleanup - 7 plain directories — new repos with initial commits
- 1 plain directory — already on GitHub, just needed
git initand remote - 3 detached repos — already correct, pushed latest
- 1 detached repo — new GitHub repo created
Total: 47 services, all on GitHub, all private, all in one org.
The script itself is 200 lines of bash. It ran in about three minutes. The technical debt it resolved had been accumulating for six months.
Sometimes the most impactful engineering work isn’t a new feature. It’s making sure the features you already built will still exist tomorrow.