如何用 AI 協助分階段程式碼遷移,避免大型 PR
Shipping AI-assisted migrations without a big-bang PR
Sherotree 分享以 AI 協助程式碼遷移時,應按單一功能範圍拆成可審查、可回退的小型 PR,而不是一次完成整項遷移。每個切片應保留舊有行為契約,以 fixtures 或 golden snapshots 驗證,並在涉及副作用時用功能旗標或雙寫分階段切換。作者建議把刪除舊路徑留到獨立 PR,並由人手確認回退方案和關鍵頁面。
I asked an agent once to “migrate our data-fetching layer to the new client.” It came back with a 90-file PR, green CI, and a cheerful summary. I still couldn’t ship it. Reviewers bounced it. Rollback was a fantasy. Two weeks later we finished the same migration in six small PRs and nobody hated us.
AI is great at the mechanical parts of a migration. It is terrible at deciding how much to change in one go. Big-bang PRs feel fast because the agent can rewrite overnight. They are slow in calendar time because humans cannot review, stage, or revert them safely. Here’s how I run AI-assisted migrations so each slice is mergeable.
Why big-bang migrations fail even when CI is green
A migration PR is not a refactor. You are changing a contract: old API → new API, old store → new store, class components → hooks, CSS modules → something else. The failure modes are different:
- Review theater — nobody can hold 90 files of “same behavior, different shape” in their head.
- Hidden coupling — the agent updates call sites the tests don’t cover; production finds them.
- No rollback story — reverting means undoing half the app at once, so you don’t revert; you hotfix forward.
- Mixed intents — rename + API swap + “while I’m here” type tightening in one diff. You can’t bisect.
Green unit tests only prove the files the agent touched still pass their suites. They do not prove the migration is safe to flip for every route.
Slice by surface, not by “finish the migration”
I never prompt “migrate everything to X.” I pick a surface: one route group, one feature module, one package boundary. Example:
## Migration slice: settings routes only
Scope: `src/app/settings/**` and colocated tests/fixtures
Goal: replace `legacyFetch` with `apiClient` for settings pages
Out of scope: billing, onboarding, shared hooks used outside settings
Stop if a shared helper needs a signature change — list it, don’t chase it
That stop rule is the whole game. Migrations explode when the agent follows an import into shared land and “fixes” the shared helper for everyone in the same PR.
A useful heuristic: one PR should be reviewable in under fifteen minutes, and should have a clear “if this breaks, revert this PR” story. If the diff needs a guided tour, it’s too big.
Keep each PR reviewable (same rules as agent refactors)
I reuse the same three gates I use for agent-driven refactors:
- One intent — migrate call sites or change the shared client or delete the old path. Not all three.
- Narratable diff — PR description is 3–5 bullets a human wrote, not the agent’s essay.
- Contract-preserving tests — same public behavior asserted, preferably same test names where they still fit.
Order matters. I usually land the new client / adapter behind a flag or unused export first (tiny PR), then migrate one surface to call it (small PR), then delete the old path when the last surface is gone (delete PR). Agents love to do all three. I don’t let them.
Fixture and golden snapshots before you rewrite
Before the agent touches production code, I ask it to freeze the old behavior with fixtures or golden snapshots the new path must match.
// golden: settings profile payload shape (capture once from staging)
export const settingsProfileGolden = {
id: "u_123",
displayName: "Ada",
prefs: { theme: "system", digest: "weekly" },
} as const;
Then the migration PR gets a dual assertion:
it("matches legacy settings profile contract", async () => {
const fromLegacy = await legacyFetchSettingsProfile(userId);
const fromNew = await apiClient.settings.profile(userId);
expect(fromNew).toEqual(fromLegacy);
expect(fromNew).toMatchObject(settingsProfileGolden);
});
If the new client “improves” the shape (renames a field, drops a null), the golden fails on purpose. That’s the point. Migrations should not silently change contracts unless the PR title says so and the changelog mentions it.
For UI migrations, I do the same with screenshot or DOM snapshots on the critical screens of that surface only — not the whole app. Whole-app visual baselines are how you get 400 flaky diffs and merge with eyes closed.
Feature flags and dual-write windows
For anything with side effects (writes, analytics, billing-adjacent), I want a dual-write or flag window, not a hard cutover in the same PR as the rewrite.
Pattern I prompt for:
1. Add `useNewSettingsClient` flag (default off) — no call-site changes yet
2. Behind the flag, call apiClient; else legacyFetch
3. Log mismatches for 48h on staging (read path) before enabling for one cohort
4. Separate PR to default the flag on for settings only
5. Separate PR to delete legacy path once flag is 100% and mismatch log is empty
The agent can implement step 1–2 in one PR if the diff stays small. Steps 3–5 stay human-scheduled. “AI finished the migration” is not the same as “we flipped traffic.”
If dual-write is too heavy (pure read UI, no durable state), a flag still buys you a one-line rollback without reverting a giant commit.
The rollback story has to fit in a sentence
Before I merge a migration slice, I write the rollback out loud:
- Flag off — preferred. Instant. No git archaeology.
- Revert this PR — only if the PR is small and doesn’t leave the shared client half-deleted.
- Forward fix — last resort; means the slice was too big or mixed with unrelated cleanup.
If the honest answer is “we’d have to revert six stacked PRs and redeploy shared types,” the slice was wrong. Split again.
What belongs in the agent prompt vs the human checklist
Agent prompt (mechanical):
- Exact file fence and stop rule
- Old symbol → new symbol mapping
- “Do not change public types unless listed”
- “One commit; do not delete the old path in this PR”
- “Update colocated tests only; do not invent new e2e coverage”
- “After edits, run:
pnpm test settingsand paste the summary”
Human checklist (judgment):
- [ ] Diff is one surface and one intent
- [ ] Golden / fixture still asserts the old contract (or intentional change is named)
- [ ] Flag or dual-write path exists if writes/side effects are involved
- [ ] Rollback sentence is written in the PR body
- [ ] Shared helpers outside the fence were not rewritten “for convenience”
- [ ] Delete-the-old-path is a future PR, not hidden in this one
- [ ] I actually opened the critical route in the browser once
I keep that checklist in the PR template. The agent doesn’t get to mark it done for me.
A concrete prompt template I reuse
You are migrating ONE surface. Do not finish the whole migration.
Surface: src/features/settings/**
Replace: legacyFetch(path) → apiClient.request(path) for settings modules only
Rules:
- Do not edit files outside the surface + their colocated tests
- Do not change apiClient’s public types
- Do not delete legacyFetch
- Do not rename symbols unrelated to the fetch swap
- Keep existing test names; update implementations to hit apiClient via the test double
- Stop and list paths if a shared helper under src/lib/** must change
When done:
1. Summarize files touched (bullet list)
2. Paste test output for the settings package
3. Propose the NEXT surface only — do not start it
That last line matters. Agents love to keep going. Your job is to end the turn.
What “done” looks like for a migration program
Done is not “the agent said migration complete.” Done looks like:
- N small merged PRs, each with a rollback sentence
- Old path deleted in a dedicated PR after traffic is off it
- Goldens either retired (contract intentionally changed) or still green
- No “temporary” dual code left rotting for months without an owner
AI shortens the typing. It does not shorten the need for staged cutovers. If you let it ship a big-bang PR, you didn’t save time — you borrowed it from the next outage.
Slice the surface. Freeze the contract. Flag the cutover. Keep the delete for last. Then let the agent type.
來源:DEV Community · dev.to