9 min read

Diff, Merge, Breathe: How to Safely Unify Code Copies That Drifted Apart

A step-by-step field guide to real-world deduplication: find every copy, separate the bugs from the requirements, pin behavior with tests, and merge diverged duplicates without breaking production.

Textbook deduplication is easy: two identical methods, delete one, done. Reality is nastier. The duplicate was pasted three years ago. Since then one copy got a bug fix, the other got a feature, and both got reformatted. They’re 70% the same, and you have no idea which parts of the other 30% are load-bearing.

Delete the wrong 30% and you don’t get cleaner code. You get an outage with a refactoring commit at the top of the blame.

Copy, Paste, Regret made the case for why duplicated knowledge is dangerous. This article is about the harder problem: the duplicates exist, they’ve drifted for years, and you want them gone without taking production down. Since most of this work now happens with an LLM tool in the loop, we’ll close with a map of which steps you can hand to the model and which ones it will confidently get wrong.

The prime directive: treat every difference between copies as a potential requirement until proven otherwise.

Step 1: Find the copies, all of them

Don’t trust your memory. Measure. Clone-detection tools find duplicates grep can’t, including reformatted and renamed ones:

  • PMD’s CPD (Copy/Paste Detector): an open-source token-based duplicate finder that ships with PMD. Reach for it when your codebase is mostly Java (it also covers C/C++, Kotlin, Go, and more) and you want a fast report or a Maven/Gradle build gate.
  • SonarQube: SonarSource’s code-quality platform (free Community Edition) that tracks duplication density per project over time. Use it when your team already runs it in CI, because then the duplication report costs you nothing extra.
  • jscpd: a community-maintained npm tool that detects copy-paste across roughly 150 languages in a single run. Use it when duplication spans layers, like your Java services and their TypeScript frontend.

These tools only compare code, so they miss the same rule written somewhere else: a database constraint, a config file, a mobile app in another language. Find those by hand with grep, searching for domain terms like shipping or threshold rather than code fragments.

Step 2: Diff the variants and build a divergence table

Put the copies side by side and list every difference. For each one, answer one question: accidental or intentional?

Say we found three copies of “calculate order discount”:

Difference checkout admin email Verdict
Loyalty tier bonus ✅ has it ✅ has it ❌ missing Accidental: added in 2024, email copy missed
Rounds half-up ❌ rounds down Accidental? Needs finance to confirm
Zero-total guard Same
Manual override param ✅ has it Intentional: admin-only feature

This table is the most valuable artifact of the whole effort, and the Verdict column is the hard part. Two sources fill it in.

git history handles the mechanical rows. git log -L traces the history of a single method, so you can find the commit that added the loyalty tier bonus, confirm it never landed in the email copy, and mark that row accidental with confidence.

People handle the rest. History shows when the admin copy started rounding down, not whether anyone meant it to. That’s a question for finance, so go ask. Both answers are useful: “yes, admin refunds round down by policy” makes the row intentional and that behavior has to survive the merge, while “wait, admin rounds down?” is a bug you just found for free.

Step 3: Pin current behavior with characterization tests

Two terms worth defining before we use them. A characterization test records what code actually does: you run it, observe the output, and assert exactly that, even when you believe the output is wrong. The name comes from Michael Feathers’s Working Effectively with Legacy Code. An aspirational test is the normal kind you write for new code: it asserts what the code should return according to the spec, whether or not the code gets it right today.

For this refactor, aspirational tests are the wrong tool. You’re not trying to prove the code is correct yet; you’re trying to prove you didn’t change anything by accident. So before touching anything, characterize each copy:

@DisplayName("characterization: discount variants (pre-unification)")
class DiscountCharacterizationTest {

    @Test
    void checkout_loyaltyGold_on_99_99_order() {
        Order order = order(new BigDecimal("99.99"), Tier.GOLD);
        assertEquals(new BigDecimal("15.00"), checkoutDiscount.calculate(order));
    }

    @Test  // known divergence, see JIRA-4821
    void admin_sameOrder_roundsDown() {
        Order order = order(new BigDecimal("99.99"), Tier.GOLD);
        assertEquals(new BigDecimal("14.99"), adminDiscounts.calculate(order));
    }
}

Note the second test: it pins 14.99, the rounding behavior we suspect is a bug. That’s deliberate. The suite’s job is to detect change, and it turns “I think this is safe” into “the suite proves exactly which behaviors changed.” When you fix the rounding divergence later, you’ll update that test on purpose, with a commit message saying why. If the inputs are rich (real orders are), also replay a sample of production inputs through each copy and record the outputs.

Step 4: Build the canonical version, but don’t delete anything yet

The task in this step: write one new implementation that will eventually replace every copy, in its own class, while all three old copies keep running untouched. Yes, you’re temporarily adding a fourth implementation. That feels backwards, but the old copies are your reference behavior and your rollback path until migration is done.

The divergence table from Step 2 is your design brief. Read it row by row:

  • Rows marked same become the shared core.
  • Rows marked accidental get resolved to the agreed-correct behavior.
  • Rows marked intentional become explicit parameters or methods, so the variation stays visible instead of living in a separate copy.

For our discount example, each line traces back to a table row:

// New file. The three old copies stay untouched for now.
public final class DiscountCalculator {

    public BigDecimal calculate(Order order) {
        // Zero-total guard: identical in all three copies.
        if (order.total().signum() == 0) return BigDecimal.ZERO;

        // Loyalty tier bonus: the email copy was missing it (accidental).
        BigDecimal discount = order.total().multiply(rateFor(order.tier()));

        // Rounding: finance confirmed half-up; admin's round-down was a bug.
        return discount.setScale(2, RoundingMode.HALF_UP);
    }

    // The admin-only override (intentional) becomes its own named
    // method instead of a divergent copy of the whole algorithm.
    public BigDecimal calculateWithOverride(Order order, BigDecimal overrideAmount) {
        // validate the override, then delegate the rest to calculate()
    }
}

Resist improving the API, renaming things, or fixing unrelated warts in the same change. Reviewers can verify “these do the same thing” or “this is better,” not both at once.

Step 5: Migrate call sites one at a time, and verify in the dark

Switch consumers to the canonical version one call site per commit, lowest-risk first (the email receipt, not the checkout). Each migration stays small, revertable, and reviewable.

For the high-stakes call site, don’t guess. Shadow-compare. Run both implementations in production, serve the old result, and log disagreements:

Money legacy = legacyCheckoutDiscount.calculate(order);
Money unified = discountCalculator.calculate(order);
if (!legacy.equals(unified)) {
    metrics.increment("discount.unification.mismatch");
    log.warn("discount mismatch orderId={} legacy={} unified={}",
        order.id(), legacy, unified);
}
return legacy;  // still serving the old answer while we watch

A few days of zero mismatches (or mismatches that match your divergence table exactly) buys the confidence to flip. Too little traffic for that? Replay historical inputs offline instead.

Only after every call site is migrated do the old copies get deleted, and deleting them is the whole point. Leave the ancestors in place and all you’ve accomplished is adding a fourth copy to maintain.

Step 6: Make the regression structurally hard

Deleting the copies fixes today’s problem. But something in how your team works let those copies pile up in the first place, and if that doesn’t change you’ll be back here in a year or two. Three guardrails worth an afternoon:

  • Run clone detection in CI (CPD with a failure threshold, or SonarQube quality gates) so new duplication fails loudly.
  • Make the canonical class easy to find and use. Duplication often happens because the shared version was awkward or unknown. Discoverability is a deduplication tool.
  • Document the intentional variations in the code (named options, Javadoc). The next engineer should never wonder whether a difference is a bug or a requirement. That wondering is where this mess started.

Running this playbook with an LLM

As promised: almost nobody walks these steps by hand anymore. The model speeds them up, but unevenly.

Step 1 is where it shines. Clone detectors like CPD compare tokens, so they only catch copies that still look alike. Rename the method, restructure the loop, or rebuild the same rule in another service, and those copies slip through. An LLM searches by what the code does, so it finds the ones that no longer resemble each other:

Find every place in this codebase that computes an order discount,
in any language or layer. Include renamed methods, SQL, and config.
List file, method, and how each one differs from the others.

The last line matters: you’re asking for leads, not conclusions.

Step 2 gets faster, but the table is a draft. The model can fill the “difference” column in a minute. The “verdict” column is yours: it can read git history, but not the conversations behind those commits, and it can’t ask your finance team.

Step 3 is a great delegation. Characterization tests are mechanical work. One rule: run them against today’s code before trusting them. A hallucinated expectation pinned as “current behavior” turns a lie into a regression suite.

Steps 4 and 5 are where trust burns you. Ask a model to unify three diverged implementations and it will cheerfully declare them equivalent and hand you one merged version in a single shot. That’s the heroic diff this article warns against, at machine speed. Keep the incremental migration and the shadow comparison. Those guardrails are what make AI-speed refactoring survivable.

Step 6 closes the loop. Put the canonical module’s name in your assistant’s instruction file so tomorrow’s generated code reuses it instead of reinventing it.

None of this changes the playbook, only how much of it you type yourself. Let the model do the archaeology and the drafting; keep the judgment and the verification.

The takeaway

Don’t fix diverged duplicates in one heroic diff. Find every copy, table the differences, pin behavior with tests, unify deliberately, migrate incrementally, verify in the dark, then delete. It’s slower than it sounds and much faster than the alternative, which is doing the same work at 2 a.m., one missed copy at a time.

And once the copies are gone, keep them gone: the cheapest deduplication is the one you never have to do, which is the first article’s whole argument.