Rewriting a large production system is difficult even when the target language, architecture, and requirements are well understood. Rewriting hundreds of thousands of lines while the original product continues to evolve is an even bigger challenge.
GitHub recently completed a major rewrite of the Copilot agent runtime from TypeScript to Rust. The production runtime reached more than 800,000 lines of Rust, with AI agents producing most of the code. The work was delivered incrementally through 128 pull requests instead of being held for one large cutover.
The interesting part is not the number of lines.
The real engineering lessons are about how to approach a large rewrite, how to use AI coding agents without giving up engineering control, how to detect behavioral differences, and why testing and migration strategy matter more than simply translating code from one language to another.
Why GitHub Rewrote the Copilot Runtime
The Copilot agent runtime had originally been built using TypeScript, Node.js, and V8.
As the runtime became a shared foundation for more Copilot products and SDKs, GitHub needed different characteristics from its underlying engine. The runtime needed to be easier to embed inside applications, have low overhead, support multiple programming languages, and provide predictable resource usage.
GitHub selected Rust for those requirements rather than because TypeScript was considered unsuitable in general. The engineering team specifically wanted a native runtime that could expose a C-compatible interface and support SDKs across C#, TypeScript, Python, Rust, Go, and Java.
That distinction matters.
A large rewrite should begin with a concrete engineering problem rather than a preference for a particular programming language.
The Scale Was Larger Than the Initial Estimate
One of the first lessons was that estimating a rewrite by simply counting lines of code can be misleading.
The initial estimate in May 2026 put the runtime at approximately 130,000 lines of TypeScript. As the migration progressed, more runtime functionality was separated from the terminal UI, while new TypeScript code continued to enter the repository.
By the end, GitHub estimated that approximately 430,000 lines of production TypeScript had passed through the porting process. The resulting Rust implementation grew to more than 800,000 production lines.
The difference happened because the project was not static.
While one team was migrating code, other developers were continuing to add features and make changes.
This creates an important lesson for engineering teams:
A rewrite should be estimated against system behavior and dependencies, not just the number of lines currently visible in a repository.
A 100,000-line application can be harder to rewrite than a 300,000-line application if its dependencies, integrations, runtime assumptions, and testing surface are significantly more complicated.
The Biggest Decision: Rewrite in Place
GitHub considered two broad approaches.
The first was a big-bang rewrite:
Existing TypeScript
|
| Long rewrite period
v
Complete Rust implementation
|
v
Production cutover
The second was an incremental migration:
TypeScript Component
|
v
Rust Replacement
|
v
Production Validation
|
v
Remove Old Implementation
GitHub chose the second approach.
Each component was replaced with a Rust implementation while the rest of the runtime continued operating. The main branch remained active throughout the migration.
This approach provided several practical benefits.
Big-Bang Rewrite | Incremental Rewrite |
|---|---|
Large final cutover | Small production changes |
Large debugging surface | Smaller debugging surface |
Long period before real validation | Continuous validation |
Greater integration drift | Less migration drift |
Difficult rollback | Smaller changes are easier to revert |
Existing development may need to pause | Normal development can continue |
For a production system, this difference can be significant.
Small Pull Requests Reduced Migration Risk
GitHub's migration was delivered through many smaller changes rather than one enormous pull request.
A typical migration could conceptually look like this:
PR 1
TypeScript Component A
↓
Rust Component A
PR 2
TypeScript Component B
↓
Rust Component B
PR 3
TypeScript Component C
↓
Rust Component C
Each change had a smaller scope.
That made code review easier and made failures easier to associate with a particular change.
GitHub reported that the project shipped 135 releases during roughly fourteen and a half weeks of porting, including both prerelease and stable releases. This allowed migrated components to be exercised in deployed builds instead of waiting until the entire rewrite was complete.
This is a useful strategy for any large migration:
Make every step small enough that the team can understand what changed and what could have caused a regression.
AI Changed the Economics of the Rewrite
The most unusual aspect of the project was the role of AI coding agents.
GitHub reported that AI agents wrote most of the production Rust code. The project was primarily driven by one developer with support from the wider team, rather than requiring a dedicated team for a year or two.
But that does not mean the process was simply:
"Rewrite this application in Rust."
and then waiting for the result.
The engineering process still required:
Breaking the migration into components
Defining the desired architecture
Reviewing generated changes
Resolving merge conflicts
Running tests
Investigating regressions
Improving prompts and instructions
Validating behavior
Monitoring deployed builds
The important lesson is that AI reduced the cost of producing code, but it did not eliminate the need for engineering judgment.
Give AI Agents a Precise End State
One of GitHub's lessons was that vague migration instructions produced incomplete migrations.
An instruction such as:
Port this component to Rust.
can leave important questions unanswered.
For example:
Should I port only business logic?
Should I port I/O?
Should I port orchestration?
Should temporary TypeScript execution remain?
Should the public API change?
Should compatibility behavior remain?
Should the old implementation be deleted?
GitHub found that agents performed better once the desired end state was explicitly defined.
For a large migration, an instruction should describe the architectural destination, not just the next coding task.
A better migration specification might look like:
Goal:
Replace the existing TypeScript implementation with Rust.
Requirements:
1. Preserve externally observable behavior.
2. Preserve existing public contracts.
3. Move logic, state ownership, and orchestration.
4. Do not remove existing functionality.
5. Maintain existing end-to-end tests.
6. Remove the TypeScript implementation once the Rust version is validated.
7. Keep the migration compatible with the current runtime until the replacement is ready.
The exact instructions will vary by project, but the principle is broadly useful.
AI-Generated Code Still Needs Strong Review
GitHub's experience also demonstrates why code review becomes more important, not less important, when AI generates large amounts of code.
During the migration, the team used agents to review changes, rebase branches, identify potential problems, and improve future migration instructions.
One example described by GitHub involved a migration that accidentally lost an existing method. An automated mechanism had effectively treated the missing API as an acceptable schema change.
Human review identified that the method should not have disappeared, and the implementation was restored in Rust.
The lesson is straightforward:
A passing automated check does not necessarily mean the migration is correct.
Reviewers need to compare behavior, contracts, and architectural intent.
Preserve the Original System as a Behavioral Oracle
One of the most important lessons from a language rewrite is that existing behavior is often more valuable than existing implementation details.
Suppose a TypeScript function behaves like this:
function normalizeInput(value: string): string {
return value.trim().toLowerCase();
}
The Rust version might look simple:
fn normalize_input(value: &str) -> String {
value.trim().to_lowercase()
}
But real production code contains much more complicated behavior.
The original implementation may have undocumented assumptions about:
Null or missing values
Error handling
Ordering
Timeouts
Cancellation
Retry behavior
Unicode handling
Serialization
State transitions
Concurrency
A rewrite should therefore ask:
What behavior must remain unchanged?
rather than:
How do I translate this code line by line?
End-to-End Tests Became Critical
GitHub specifically highlighted end-to-end testing as one of the most important lessons from the migration. Many regressions were related to missing functionality that insufficient E2E coverage failed to detect.
This makes sense because unit tests usually validate individual functions.
A runtime migration can break the interaction between those functions without breaking their individual tests.
Consider:
Agent
|
+--> Session
|
+--> Tool
|
+--> Permission
|
+--> Event System
|
+--> Host Application
Every individual component may pass its unit tests while the complete workflow fails.
An E2E test can catch that difference.
For example:
Start Session
↓
Send User Request
↓
Select Tool
↓
Request Permission
↓
Execute Tool
↓
Receive Result
↓
Continue Agent Loop
↓
Return Final Response
Testing the complete sequence is essential when the migration changes the underlying runtime.
Do Not Rewrite the Tests Along With the System
There is another subtle issue with migration testing.
If you rewrite both the application and its tests at the same time, you can accidentally lose your reference point.
For example:
Old Implementation
|
+---- Old Tests
|
v
New Implementation
|
+---- Rewritten Tests
If both implementations and tests are changed together, the tests may start validating the new behavior rather than the original behavior.
A stronger approach is:
Original Behavior
|
v
Existing Tests
|
v
New Implementation
The existing tests become a behavioral oracle.
Only after the migration is stable should tests themselves be intentionally redesigned.
Most Regressions Came From Behavior, State, or Missing Work
GitHub grouped its known regressions into several recurring categories.
The major patterns included:
Incomplete migrations
State, ownership, and lifetime problems
Behavioral contract mismatches
Host and interoperability boundary problems
Incorrect test assumptions
By September 14, GitHub reported dozens of known regressions associated with the migration, all of which had been fixed at that point.
The number itself is less useful than understanding why they happened.
Incomplete Migration
A small part of the original functionality can be easy to overlook.
This is especially likely when an application has implicit behavior spread across several layers.
State and Lifetime Differences
TypeScript and Rust manage state differently.
Rust makes ownership and lifetimes explicit, which can expose assumptions that were less visible in the original implementation.
Behavioral Differences
Two libraries may provide similar functionality but have different edge-case behavior.
Replacing one library with another is therefore not always a drop-in translation.
Host Boundary Problems
Interop creates another class of bugs.
Data crossing from Rust to TypeScript or another host language may require:
Serialization
Copying
Conversion
Synchronization
Callback handling
Each boundary creates opportunities for unexpected behavior.
A Native Rewrite Can Still Introduce Performance Regressions
It is tempting to assume that moving from TypeScript to Rust automatically makes everything faster.
GitHub's experience shows why that assumption is incorrect.
Some performance regressions occurred because the new implementation introduced unnecessary work around the language boundary or failed to preserve existing optimizations. Examples included redundant serialization, locking, polling, excessive concurrency, and unnecessary copying.
Consider a simplified example:
Application
|
v
Rust
|
v
Serialize JSON
|
v
Host Runtime
|
v
Deserialize JSON
The Rust code itself may be fast, but the overall workflow still pays for serialization and deserialization.
A native rewrite should therefore measure the entire system rather than focusing only on individual functions.
Do Not Optimize Everything During the Port
Another useful lesson was to avoid combining too many changes.
GitHub deliberately treated the migration primarily as a behavior-preserving port rather than simultaneously redesigning algorithms, fixing unrelated bugs, and optimizing every component.
This creates a much cleaner debugging model.
If the new version becomes slower, the team can ask:
Did the language migration introduce the regression?
rather than:
Did the language migration,
architecture redesign,
algorithm change,
dependency replacement,
and optimization introduce the regression?
Once the port is stable, optimization becomes much easier to reason about.
Dependency Migration Is a Separate Problem
A language migration rarely means changing only the source code.
Dependencies also have to change.
GitHub reported removing approximately 60 npm dependencies that were used only by runtime code after the corresponding functionality moved to Rust. Some dependencies remained because they were still required by the CLI.
A dependency migration should therefore be treated as its own workstream.
For each dependency, ask:
Question | Why It Matters |
|---|---|
What does the dependency provide? | Defines the actual requirement |
Is there a Rust equivalent? | Determines whether replacement is practical |
Does behavior match? | Prevents subtle regressions |
Is performance comparable? | Avoids unexpected degradation |
Does the new dependency add risk? | Controls supply-chain complexity |
Can the dependency be removed entirely? | Simplifies the final system |
The goal should not be to find a crate with the same name or API.
The goal is to preserve the required behavior with an appropriate implementation.
Temporary Interop Should Have an Exit Plan
During the migration, TypeScript and Rust needed to communicate.
This creates a temporary architecture:
TypeScript
|
v
Interop Layer
|
v
Rust
As more code moves to Rust, the boundary changes:
Rust
|
v
Interop Layer
|
v
Rust
Eventually, that boundary can be removed.
GitHub's migration ultimately eliminated the temporary internal TypeScript/N-API seam after the runtime became fully Rust.
This is an important architectural principle.
Temporary compatibility layers are useful, but they should have:
A clear purpose
An owner
A removal condition
Tests
A migration plan
Otherwise, temporary infrastructure can become permanent technical debt.
AI Agents Need Feedback Loops
Another major lesson is that AI-assisted development improves when failures are turned into reusable knowledge.
GitHub described responding to individual failures and then improving the migration process so similar failures became less likely in later pull requests. Session logs were also turned into evaluations, and instructions given to coding agents evolved throughout the project.
This can be represented as:
Agent Generates Code
|
v
Tests / Review
|
v
Failure Detected
|
v
Root Cause Identified
|
v
Prompt / Tool / Test Improved
|
v
Next Migration
That feedback loop is more valuable than simply asking an AI model to generate more code.
The system becomes better at the specific engineering task as the team learns what the model gets wrong.
Best Practices for Large AI-Assisted Rewrites
If your team is considering a similar migration, these practices can reduce risk.
1. Define the End State
Describe what the final architecture should look like.
Do not limit the instructions to "convert this file."
2. Keep Production Changes Small
Prefer component-level migrations over massive pull requests.
3. Preserve Existing Behavior
Treat the original implementation as a behavioral reference.
4. Keep E2E Tests Stable
Use them as a validation mechanism throughout the migration.
5. Review Dependencies Separately
Do not assume equivalent libraries have equivalent behavior.
6. Track Temporary Code
Every compatibility layer should have a path to removal.
7. Measure the Whole System
Measure startup, memory, latency, throughput, and resource usage at the application level.
8. Turn Failures Into Rules
When an agent repeatedly makes the same mistake, improve the prompt, test, tooling, or repository guidance.
9. Rebase Frequently
Long-lived migration branches accumulate conflicts and increase the chance of losing unrelated changes.
10. Optimize After Correctness
First make the new implementation behave correctly. Then redesign and optimize where the new language provides opportunities.
Common Mistakes to Avoid
Large rewrites often fail because teams focus too heavily on code conversion.
Avoid these patterns:
"The compiler will catch everything."
It will not catch behavioral differences.
"The new language is faster, so performance will improve automatically."
Interop, allocation, locking, and algorithmic choices still matter.
"We can rewrite the tests later."
That can remove your most valuable behavioral reference.
"AI can handle the entire migration independently."
AI can generate substantial amounts of code, but architecture, validation, and risk management still require engineering oversight.
"Let's redesign everything while we're here."
That makes regressions much harder to diagnose.
What This Means for Developers
GitHub's experience provides a broader lesson about modern software engineering.
AI coding agents can make previously expensive engineering projects much more practical. But the benefit does not come simply from generating millions of lines of code.
The real advantage comes from combining AI-generated implementation with:
Clear architectural goals
Small changes
Automated testing
Human review
Continuous integration
Production validation
Strong feedback loops
The migration also demonstrates that a successful rewrite is measured by more than the amount of code produced.
The important questions are:
Does the new system preserve behavior?
Does it reduce the original architectural constraints?
Can it be maintained?
Can it be tested?
Can it be deployed safely?
Does it create a better foundation for future work?
Those questions remain important regardless of whether AI writes 1% or 90% of the code.
Advantages of an Incremental AI-Assisted Rewrite
An incremental approach provides several practical advantages:
Smaller pull requests are easier to review.
Production validation happens continuously.
Regressions are easier to associate with recent changes.
Developers can continue normal feature development.
AI agents can work on bounded migration tasks.
Lessons from one migration can improve the next.
Temporary compatibility code can be removed progressively.
Disadvantages and Trade-Offs
The approach also has costs:
The project can take longer than a theoretical big-bang implementation.
Two languages may coexist for an extended period.
Temporary interoperability increases complexity.
Developers need to manage frequent rebases.
AI-generated code still requires careful review.
Some regressions may only appear under real workloads.
Dependency replacement can introduce additional compatibility problems.
The right strategy depends on the system, team, release model, and tolerance for migration risk.
A Practical Checklist for Your Next Rewrite
Before starting a large rewrite, answer these questions:
Area | Question |
|---|---|
Goal | Why are we rewriting the system? |
Scope | What exactly belongs in the migration? |
Architecture | What should the final architecture look like? |
Compatibility | What behavior must remain unchanged? |
Testing | Do we have sufficient E2E coverage? |
Dependencies | Which libraries need replacements? |
Interop | How will old and new components communicate? |
Rollout | Can changes be deployed incrementally? |
Monitoring | How will production regressions be detected? |
Cleanup | When will temporary migration code be removed? |
AI Usage | Which tasks can agents safely automate? |
Review | What requires human architectural review? |
If several of these questions cannot be answered, the rewrite probably needs more planning before implementation begins.
Summary
GitHub's rewrite of the Copilot runtime demonstrates that a large language migration is fundamentally an engineering and architecture project, not a code translation exercise.
The runtime moved from TypeScript and Node.js to Rust through incremental component replacements rather than one large production cutover. AI agents generated most of the new Rust implementation, but the project still depended heavily on testing, code review, production validation, and continuous refinement of the development process.
The most useful lessons are broadly applicable:
Define the final architecture clearly.
Break large migrations into small production changes.
Preserve existing behavior before redesigning the system.
Keep end-to-end tests independent and stable.
Treat dependency migration as a separate engineering problem.
Expect behavioral and performance regressions.
Use AI to accelerate implementation, not to replace engineering judgment.
Turn every discovered failure into a better test, tool, prompt, or process.
The most important takeaway is that AI can change the economics of a large rewrite, but engineering discipline determines whether that rewrite succeeds.

Join the conversation! Your thoughts help the community grow.