· 8 min read
Make AI Refactors Boring: A Step-by-Step Workflow for Multi-File Changes
By D. Kowalski
- tools
Use an AI agent for a multi-file refactor when the transformation is mechanically broad but behaviorally specific—and make it prove each slice before it moves on. Don’t hand it “modernize the auth layer”; hand it a branch, a before-and-after contract, a search command, and a test command.
The agent is good at finding repeated patterns, carrying a rename through imports and fixtures, and doing the tedious second pass you would otherwise postpone. It is bad at discovering the real boundary of an architectural change, noticing that two similarly named concepts are intentionally different, and deciding whether a compatibility break is acceptable. Those are still your calls.
Start in a worktree, not in the branch you were already using
A refactor gets less stressful when it has a physical boundary. Create a linked Git worktree and give the agent that directory. Your editor can stay open on the bug fix or feature you were already doing, while the agent has one place to make a mess.
git fetch origin
git worktree add -b refactor/request-context ../app-request-context origin/main
cd ../app-request-context
git status --shortgit worktree add creates another checkout attached to the same repository, with its own HEAD and index. When you are done, remove it with git worktree remove ../app-request-context; don’t delete the directory first and leave stale metadata around. Worktrees are especially useful here because an agent run is an experiment until you have reviewed it.
If your repository uses generated files, local secrets, or tool caches, stop before launching the agent and make sure this worktree has the same safe setup as the main checkout. An agent that cannot run the actual test command tends to substitute confidence for evidence.
Write the contract before the prompt
The useful unit of instruction is not “refactor X.” It is a contract with an observable starting state and an observable ending state. Write it in the issue, a scratch file, or the first agent message. Keep it short enough that you can tell whether it was met without rereading a design doc.
- Name the invariant: “Every request handler receives a
RequestContext; no handler reads tenant identity from process-global state.” - Name the non-goal: “Do not change public HTTP response shapes or migrate the database.”
- Name the allowed compatibility layer: “The old
getCurrentTenant()helper may remain only insrc/legacy/and must emit a deprecation warning.” - Name the proof: “
pnpm test,pnpm lint, andrg 'getCurrentTenant' src --glob '!src/legacy/**'must succeed or return no matches.”
That last line matters more than the prose. A coding agent can make a plausible patch while silently leaving ten old call sites behind. Give it a deterministic search that tells both of you when the migration is actually complete.
Have the agent map the change, then stop
Your first request should produce a map, not edits. Ask the agent to identify the definition site, every import path, entry points that construct the old value, tests and fixtures, generated code, and any external API boundary. Tell it to report likely risks and a file-by-file plan, then wait.
A decent prompt looks like this:
Map this refactor without editing files.
Goal: replace getCurrentTenant() reads in request-handling code with an explicit RequestContext passed from the HTTP boundary.
Report:
1. definition and construction sites
2. direct and indirect callers
3. tests, fixtures, and mocks affected
4. public API or background-job boundaries
5. a migration order in batches of 5–10 files
6. anything that makes this unsafe to do mechanically
Use rg and inspect the relevant files. Do not modify files yet.Read the map like you would read an unfamiliar pull request. You are looking for omissions, not elegant prose. If it does not mention CLI jobs, queue consumers, test factories, or the dependency-injection container that you know exists, correct the model now. A missing boundary at this stage becomes a strange production failure later.
Refactor in review-sized batches
Once the plan is credible, tell the agent to do one batch: usually one layer, one package, or five to ten closely related files. Require it to show the diff and run the narrowest relevant test command before it starts batch two.
For example, migrate the HTTP composition root and two handlers first. Then run the handler tests. Next migrate the service layer. Don’t let the agent touch handlers, jobs, CLI commands, mocks, and documentation in one uninterrupted run just because it can keep typing.
After each batch, inspect these commands yourself:
git diff --check
git diff --stat
git diff -- src/http src/services
git status --short
rg 'getCurrentTenant' src --glob '!src/legacy/**'git diff --check catches conflict markers and whitespace errors and exits nonzero when it finds them. git diff --stat is not a quality metric, but it is a fast alarm: if a supposedly local migration changed 97 files, ask why before asking the model to continue. Git’s normal diff supports path-limited review, so use it to review the layer the agent claims it just changed rather than paging through the entire patch every time.
Make the agent preserve behavior before it improves design
Agents love a cleanup opportunity. During a cross-cutting refactor, that is usually a liability. State a sequencing rule: first preserve behavior with the new dependency path; only then propose simplifications as separate commits. The first commit should be boring enough that a reviewer can compare old and new control flow without decoding a new abstraction at the same time.
This is where you should reject work that looks impressive but expands scope. Replacing getCurrentTenant() with explicit context is not an invitation to rewrite error handling, rename every domain object, adopt a new validation library, or rearrange the entire module tree. Ask the agent to put such ideas in a follow-up note instead.
Test the seams the agent cannot infer
Run the narrow tests after every batch, then the full suite once the mechanical migration is complete. But add targeted checks for the seams: request-to-job handoff, cron or CLI entry points, serialization boundaries, error middleware, and any test helper that creates the old implicit state. The agent sees text matches; it does not reliably see your operational assumptions.
If your test suite is slow, write one temporary characterization test around the riskiest behavior before the refactor. For the request-context example, that might assert that two concurrent requests never share tenant identity. Keep it if it names a real regression; delete it if it only tested an implementation detail you no longer want.
Finish with a human-shaped pull request
Before opening the PR, ask the agent for three things: a summary grouped by layer, a list of deliberately untouched old paths, and the exact commands it ran with their results. Then compare that report against git diff origin/main...HEAD yourself. The report is a checklist, not evidence.
Split commits when the migration has a natural sequence: introduce the new context, migrate callers, remove the old path. That makes rollback possible and review less theatrical. If owned code changed, let your repository’s review rules bring in the right people; GitHub can automatically request CODEOWNERS review for changed owned paths, but it cannot tell whether the refactor preserved your system’s actual contract.
When the PR is merged, clean up the worktree. More importantly, save the contract and the search command in the issue or PR description. The next cross-cutting change will not look identical, but the workflow will: define what must remain true, let the agent do bounded mechanical work, and keep stopping where judgment starts.