Software craft The unwritten rules

When to merge duplicate code

Don't count the copies, watch them change.

On this page · 10 sections

One logging change in httpx, the Python HTTP client, took two pull requests and twenty-three days to reach both of its clients.

On March 20, 2023, httpx merged #2547, which overhauled request logging across eight files. Among its changes, the line httpx writes for each request moved from DEBUG to INFO, so HTTP traffic became visible without turning on debug logging.

httpx has two clients. Client is the ordinary one, and AsyncClient is the same thing for async code. Each has its own _send_single_request, the method that hands a request to the network and gets a response back. The change reached Client._send_single_request. Its async counterpart, about seven hundred lines further down the same file, kept logging at DEBUG, and release 0.24.0 shipped that way. On April 12 a different contributor’s #2656, “set logging request lines to INFO level for async method also”, applied the same change to the async method. The 0.24.1 changelog says it put async requests “in line with sync requests”.

Two horizontal bars on a timeline from March 20 to May 18, 2023. The upper bar, Client._send_single_request (sync), is green and labelled INFO for the whole period, starting at #2547, merged March 20. The lower bar, AsyncClient._send_single_request (async), is grey and labelled DEBUG until April 12, where #2656 is merged, and green INFO after it. A red double arrow between the bars spans March 20 to April 12 and reads "out of step for 23 days". A dashed line marks the 0.24.0 release on April 11, with sync at INFO and async at DEBUG, and another the 0.24.1 release on May 18, with both at INFO.
Look at the grey stretch of the lower bar: the 0.24.0 release on April 11 falls inside it, so it shipped with the two methods logging at different levels.

Nobody did anything unusual here. A maintainer changed a method, the change was merged, and the method happened to have a twin. The two are nearly identical apart from await, so this looks like an easy case for removing duplication. The usual advice is to count: once is fine, twice is a smell, three times and you abstract.

Designing platforms taught me to be wary of abstractions built on resemblance alone, and that’s why I think counting is the wrong test. What decides whether two copies should be one is whether they change together, and git already has the answer. We’ll ask it, count the right history, and read the changes behind the count.

The short version

  • Use similarity to find the copies, then look at their history. Commits that change both copies suggest a shared reason to change. Commits that change only one need reading: they are either missed updates or deliberate differences.
  • httpx’s two request methods have a telling history. Since both existed, 15 commits changed both and 7 changed only one. Four of those seven were the copies falling out of step, and three were cosmetic.
  • Line history is not function history. Counted from where git’s line tracing starts, the same pair reads 29 together and 15 one-sided, because git follows the lines back to code that wasn’t either method.
  • Some resemblance is only skin deep. httpx.get and Client.get are 88% alike, but 12 of their 15 changes touched only one, and those changes belong where they landed.
  • Sharing code is not the only way to keep copies aligned. httpx’s sister project, httpcore, generates its sync code from its async code and fails the build if they differ.

Copies, shared code, or a checked master#

Imagine a product manual published in English and in French. The two editions must say the same thing, but they can’t be one text, because each reader needs their own language. There are three ways to keep them in step.

You can translate every edit by hand into both. Each edition is its own document, so every change has to be made twice, and one day it isn’t: someone corrects a step in the English manual, and the French one keeps the old step. That’s the missed update, the way duplication fails, and it’s what happened to httpx’s logging line.

You can publish one bilingual document, with both languages on every page. Every edit then reaches both readers, including the ones meant for one. Eventually the document fills up with notes like “French edition only” and “English readers skip this paragraph”.

Or you can write the English as the master, generate the French from it, and check before each release that the two match. The editions stay separate, nobody edits the French by hand, and the check catches the day they drift. The cost is a generator and a step in the release.

httpx’s two clients are in the same position as the two editions. They must behave the same, but a Python function is either sync or async, so they can’t literally be one text. The same three choices appear in code. Duplication risks missed updates. A shared abstraction risks collecting exceptions for callers that never quite agreed. Generation keeps separate copies but makes the rule that they must match something a machine checks.

Three panels. 1, translate every edit by hand (duplicate): an edit lands on the English page and the French page never gets it; fails as the missed update; in httpx, Client and AsyncClient, the I/O methods written twice. 2, one bilingual document (abstract): English and French readers both point at one page that fills with "EN only", "FR only" and "EN: skip" notes; fails as "English readers skip this" notes, a parameter and a branch each; in httpx, BaseClient, the shared code that does no I/O. 3, a master, translated and checked (generate and check): a green English master page with an arrow labelled generate to a French page, and a line between them reading "match? checked before release"; costs a script and a check that fails the build; in httpcore, the async code is the master, unasync.py writes the sync copy and its check mode fails CI on a mismatch.
Three answers to “these two must say the same thing”, and what each one costs. Read each panel top to bottom: the manual, how it fails, then the code that works the same way. httpx and httpcore use all three.

Sandi Metz described the second failure in The Wrong Abstraction. A new requirement comes along that the shared code almost fits, and the next programmer, feeling bound to keep the abstraction, will “alter the code to take a parameter, and then add logic to conditionally do the right thing based on the value of that parameter”. After enough of those, nobody can read it. The line people quote, “duplication is far cheaper than the wrong abstraction”, comes from the talk that prompted her post. Her conclusion is about what to do once you’re there: “the fastest way forward is back”, which means inlining the shared code into its callers again.

So duplication is the cheaper mistake, but it is still a mistake when the copies are meant to match. The missed logging update shipped in a release.

”Premature” means the reason to share is still a guess#

The rule of three is usually traced to Martin Fowler’s Refactoring, which credits it to Don Roberts: do something once and just do it, do something similar a second time and wince but duplicate it, and refactor the third time. Kent C. Dodds’s version is AHA, “Avoid Hasty Abstractions”, with a rule he adds beside it: “Optimize for change first”.

I agree with both. Waiting for a third copy gives you time to learn, but the count itself doesn’t tell you what you learned. Two copies can express one rule that must change everywhere. Five copies can serve five callers whose needs are starting to diverge. What you want to know is whether the copies are one idea, or two ideas that happen to look alike today. One idea changes as one: when the rule changes, every copy changes. Two ideas drift: each changes when its own callers need something. An abstraction is premature when you build it before you can tell which you have.

Fowler’s Yagni narrows it further, and I think this is the sentence people skip: “Yagni only applies to capabilities built into the software to support a presumptive feature, it does not apply to effort to make the software easier to modify.” An abstraction that exists so the code is easier to change isn’t a premature feature. On a real-time media platform I architected, I put signaling behind a plugin contract before a second transport existed. When server-mediated telephony came later, it was a factory addition rather than a rewrite. That boundary came from a known source of variation, even though only one implementation existed at first. The premature abstractions are different: they merge two pieces of code because they look alike, and bet that they’ll keep changing alike.

You don’t have to bet. Whether two copies have changed alike is a fact about the past, and it’s sitting in version control. History can’t promise that callers will agree forever, but it can show whether past edits served one requirement or several. That is a better starting point than “these look the same”.

Ask which commits changed both#

The question is simple to state. Take two copies. For every commit since both existed, did it change both, or only one? Copies that keep showing up in the same commits are coupled whether or not they share code, and that pattern is called co-change.

httpx’s two request methods show it plainly. Each of these commits changed both copies, the same way, in one go:

CommitWhat changedSyncAsync
a783fe5, Sep 7, 2020Start a timer before sending, and give the response a way to read it (#1249)yesyes
befa57c, Sep 23, 2020Fix response.elapsed: the seconds were passed to timedelta as days (#1313)yesyes
1c33a28, May 23, 2022Pass the client’s default text encoding on to each response (#2165)yesyes

Each row is one reason to change, and it reached both copies at once. The logging change from the opening is the same kind of commit with one half missing.

I wrote cochange.py to answer the question for a whole repository. It finds similar functions, traces their histories, and counts the commits each pair shares.

The first pass finds the copies. Every function with a body of six or more lines is turned into a stream of tokens, with every name, string and number replaced by a placeholder, so get(url) and head(link) come out the same:

python
cochange.py
def shape(src: str) -> list[str]:
"""The token stream with names, strings and numbers replaced, so `get(url)` and `head(link)` look the same."""
out = []
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
if tok.type == tokenize.NAME:
out.append(tok.string if keyword.iskeyword(tok.string) else "ID")
elif tok.type == tokenize.STRING:
out.append("STR")
elif tok.type == tokenize.NUMBER:
out.append("NUM")
elif tok.type == tokenize.OP:
out.append(tok.string)
return out

Keywords such as return and await stay as they are, because they’re part of what the code does. The token streams are compared with Python’s difflib ratio, which is twice the matching tokens divided by the total. Two functions at least 85% alike count as near-duplicates.

Replacing names and literals deliberately throws information away, so this finds code that looks alike, which is all it is meant to do. The next pass asks git where each candidate changed, with git log -L, which follows a range of lines back through every commit that changed it. Then it counts, and that is where it’s easy to get wrong.

Start after both copies existed#

Run git log -L on httpx’s sync _send_single_request and the trace reaches back to April 2019, long before the method had its current form. -L follows lines, not functions, so it keeps tracing those lines back into whatever code they used to belong to. The count therefore has to start when both copies existed.

Count from where the trace starts and you get nonsense: commits that look like changes to “only the async copy”, made at a time when there was no sync copy to change. A change made while there was one copy isn’t a one-sided change; it’s just a change. So the script applies two cuts:

  1. Each function’s history stops at the first commit, going back, where the traced lines weren’t mostly inside a function of the same name.
  2. A pair’s commits only count strictly after the younger copy’s first commit. Creating that copy is not a later change to it.

Here is the second cut as the script applies it to a pair:

python
cochange.py
def count_pair(git: Git, x: Function, y: Function) -> PairCount:
hx, hy = git.history(x), git.history(y)
...
birth_x = min(hx.values(), key=lambda c: c.ts)
birth_y = min(hy.values(), key=lambda c: c.ts)
since = max(birth_x, birth_y, key=lambda c: c.ts)
after = lambda h: {s for s, c in h.items() if c.ts > since.ts} # noqa: E731 - strictly after: the birth is not a change
ax, ay = after(hx), after(hy)
...
return PairCount(
since=since,
together=by_time(ax & ay),
only_first=by_time(ax - ay),
only_second=by_time(ay - ax),
...

Each copy’s history starts at its oldest commit after the first cut. The pair starts at the later of the two, and the > means the commit that made the younger copy doesn’t count as a change to it. The lines left out set sweeping commits aside, which comes up below.

For this pair, the first cut moves the start to July 2020, at e107e0f, the commit that gave both methods their current name.

Why this pair's history starts in July 2020

httpx removed its sync client in 0.8.0, in November 2019, and restored it in 0.11.0, in January 2020 (#735, “Reintroduce sync API”). During that gap there was no sync method at all, so the lines -L followed back through it belonged to other code.

The sync client was back in January, but until e107e0f, on July 17, 2020, both methods were called send_single_request, without the underscore. That is where the traced lines stop being mostly inside a function called _send_single_request, so that is where the first cut ends each history.

text
python3 cochange.py httpx httpx --pair httpx/_client.py:Client._send_single_request httpx/_client.py:AsyncClient._send_single_request
httpx at b5addb6
httpx/_client.py:Client._send_single_request ~ httpx/_client.py:AsyncClient._send_single_request
99.6% alike, 34 and 33 lines
history stops where each copy got its name: 22 earlier commits traced other code
both exist from e107e0f 2020-07-17 Tighten public client methods (#997)
after that: together 15, only first 1, only second 6
left out: 1 commit from before both existed, 1 sweeping (over 20 files)
...
only first f1157db 2023-03-20 Use standard logging style (#2547)
...
only second 4b5a92e 2023-04-12 set logging request lines to INFO level for async method also (#2656)

The two copies are 99.6% alike, not 100%, because the async copy’s await is one token the sync copy doesn’t have. Since e107e0f, 15 commits changed both, 1 changed only the sync copy, and 6 changed only the async one.

The cuts matter far more than that small difference in similarity. The first cut removed 22 commits where the traced lines were other code. The second left out 1 more, e107e0f itself, where the names were set. Put those back and count from where git’s tracing starts, April 26, 2019, and the same pair reads 29 together and 15 one-sided (4 sync, 11 async). The counted history doubles, from 22 commits to 44, and the extra half is code that wasn’t either method.

The script also sets aside sweeping commits, any commit that touched more than 20 files. Here that removes one 29-file change to how httpx configures TLS. It touched both copies, but it would have touched them whether or not they belonged together.

Read the seven one-sided changes#

The count only tells us where to look. A commit that changed one copy is either a missed update (the other copy needed it too) or a sign the two do different jobs (it only ever belonged in one). Only the diff says which, so I read all seven:

Two horizontal lanes, sync above and async below, from July 2020 to mid 2025. Fifteen green dots appear on both lanes joined by thin lines, all of them between July 2020 and May 2022: commits that changed both copies. Red squares mark one-sided changes that left the copies out of step: on the async lane, 78afd08 in September 2020 (it fixed the async copy, which had tested hasattr(stream, "close"), the sync test, before awaiting aclose()) and 0f280af in February 2021 (exceptions mapped on the async close only; a rewrite replaced both five weeks later); on the sync lane #2547 on 2023-03-20, with an arrow labelled 23 days down to #2656 on the async lane on 2023-04-12. Grey squares on the async lane mark three cosmetic one-sided changes, in 2020 and February 2025.
Fifteen changes landed in both copies; of the seven that landed in one, four left them out of step.
CommitCopyWhat the diff showed
f5c27ec, Aug 2020asyncCosmetic: a formatter dropped a trailing comma the sync copy didn’t have
78afd08, Sep 2020asyncOut of step: the async copy tested hasattr(stream, "close"), the sync copy’s test, and only then awaited stream.aclose(). Because it looked for the sync method’s name, closing an async response could skip closing its stream. The fix, titled “Properly close stream on async response close”, made it test aclose
c4d2e6f, Nov 2020asyncCosmetic: a trailing comma in a tuple
0f280af, Feb 2021asyncOut of step: wrapped the stream close so network errors come out as httpx’s own exceptions. The sync close never got it; a rewrite replaced both five weeks later
f1157db, Mar 2023syncOut of step: #2547, the INFO logging
4b5a92e, Apr 2023asyncOut of step: #2656, the same change 23 days later
4189b7f, Feb 2025asyncCosmetic: a typo in an error message only the async copy has

Four of the seven were the copies falling out of step and being pulled back, or not. Three were cosmetic. None of them gave one method a job the other didn’t have. The similarity score said these two look alike; the history says they are one idea written twice.

The same check on another pair tells a similar story. A transport in httpx is the object underneath a client that actually opens connections and sends bytes, again one sync and one async. The two transports’ __init__ methods read 12 together and 4 one-sided, and all four one-sided changes are the same kind of miss. Passing the proxy’s TLS settings through, part of HTTPS proxy support, reached the sync transport in September 2023 and the async one eight months later.

One commit counted as “together” shows the limit of counting. fd60b18, a formatting change, split a long error message in both transports and kept the f that makes it an f-string in only one. The async transport’s error then printed {proxy.url.scheme!r} literally until 336204f, 18 months later. Both copies changed in the same commit; they didn’t change the same way.

The maintainers know all this, and they manage it. In January 2024, #3042, “Keep clients in sync”, says it plainly: “Except for the async/await syntax, AsyncClient and Client are supposed to be the same.” Its author found where they had drifted the way you’d expect: “I isolated them into separate files and used git diff --no-index --word-diff asyncclient.py syncclient.py to find and fix differences.” That is a person doing by hand the check that the third answer automates.

Similar bodies, different reasons to change#

Now the opposite case. httpx has module-level functions, httpx.get(url), for one-off requests, and Client.get for code that keeps a client around. Both build a request and pass it on. Their bodies are 87.8% alike, above the 85% threshold, so by the rule of three they’re overdue for merging. Their history says otherwise:

text
python3 cochange.py httpx httpx --pair httpx/_api.py:get httpx/_client.py:Client.get
httpx/_api.py:get ~ httpx/_client.py:Client.get
87.8% alike, 34 and 28 lines
history stops where each copy got its name: 11 earlier commits traced other code
both exist from ee37a76 2020-01-08 Reintroduce sync API. (#735)
after that: together 3, only first 5, only second 7
left out: 12 commits from before both existed, 2 sweeping (over 20 files)
...

Three commits touched both, and twelve touched only one. The twelve make sense once you think about the callers. A one-off request has nowhere else to put its configuration, so the module-level function gained proxies, then proxy, then a reworked verify. A client already holds defaults, so its method gained a “use the client’s default” marker, an extensions parameter and auth=None.

Merge them and you get the bilingual manual: one function with a parameter for whichever kind of caller it’s serving today. Keeping them apart gives each job its own home. The twelve one-sided changes are the reasons for that separation, and an 88% match doesn’t outweigh them.

What happens across the repository?#

Run the script over all of httpx:

text
python3 cochange.py httpx httpx
httpx at b5addb6: 201 functions of 6+ lines under httpx/
128 near-duplicate pairs (>= 85% alike), in 28 clone groups
...
clone groups: changes to every existing member / to only some of them
G1 12 members consistent 11, partial 20 (4 sweeping left out)
...
G12 2 members consistent 15, partial 7 (1 sweeping left out)
httpx/_client.py:AsyncClient._send_single_request
httpx/_client.py:Client._send_single_request
...
all groups: 232 consistent, 73 partial (24% partial), 38 sweeping left out

It finds 128 near-duplicate pairs among 201 functions. Pairs overcount: a family of five copies makes ten pairs, and one commit that changes all five would be counted ten times. So the script also joins connected pairs into clone groups, 28 of them, and counts each commit once per group. One commit against one group is a group change. It is consistent if it changed every copy in the group that existed by then, and partial if it changed only some. “Consistent” only says every copy was touched.

Across httpx, 232 group changes were consistent and 73 partial, about a quarter. G12 is our request pair again, with the same 15 and 7.

That 24% is a share of group changes, and some of them are on purpose. To see what was inside it, I drew 20 of the 73 partial changes at random and read each diff:

Partial change, on reading the diffOf 20
Out of step: the other copy needed it too, or this one was catching up6
On purpose: the change belongs to one family of copies by design9
Cosmetic: a typo, a docstring4
Miscounted: both changed; the line tracing lost one1

All nine “on purpose” changes were in the two big groups where the near-duplicate threshold had joined the module-level functions to the client methods. That is the httpx.get and Client.get story, nine times over. The six out-of-step changes include a missing http1 argument in the async client’s proxy setup, and an empty-chunk fix that reached one decoder 22 months after its twin had it. Twenty is enough to show several different causes, but too few to read 6 in 20 as a rate.

The research agrees, and it’s worth knowing which study says what. Jens Krinke studied five open-source systems in 2007 and found that “usually half of the changes to code clone groups are inconsistent changes” (what the script calls partial), and that once a group goes inconsistent, “it is rarely the case” that later changes bring it back. Juergens and colleagues (ICSE 2009) went through over 700 inconsistent clone groups with the systems’ own developers and concluded that “nearly every second unintentionally inconsistent change to a clone leads to a fault”. Kim and colleagues (ESEC/FSE 2005) found many clones short-lived: of those that disappeared, 48% to 72% did so within an average of eight check-ins, so “aggressive, immediate refactoring may not be necessary”.

These findings aren’t in conflict. Some copies are one idea, and diverging is a bug. Others are two ideas, and diverging is the point. Co-change is how you tell which you have.

Where the counting can still go wrong

git log -L matches lines, so a method moved to another file or renamed loses its history from before the move. The script stops rather than guess, which undercounts. flask is the extreme case. Its app code was split into a new module in 2023, so every copy’s history starts again there: run on flask at d086db8, the script finds 22 near-duplicate pairs but only 11 group changes to count, against 305 in httpx.

Formatting commits that touch both copies inflate “together”. The 20-file sweep filter catches the big ones, not a two-file tidy-up. Clone groups are built from connected pairs, so two members of one group need not be 85% alike to each other.

The one miscounted change in the sample was a 2019 commit that changed both Headers.values and Headers.items, where the trace of one doesn’t reach that far back. Treat the output as a list of diffs to read, over a stated window.

One project family uses all three answers#

httpx and httpcore, the library underneath it that handles connections, come from the same organisation. Between them they use all three answers from the manual.

Share what doesn’t vary. httpx’s BaseClient holds what both clients do the same way and never waits on the network: merging URLs, headers and cookies, building requests, and working out where a redirect goes. Both clients inherit it. That’s the bilingual document, kept to the part that reads the same for every caller, the way a manual’s diagrams and part numbers need no translation.

Copy what can’t be shared. The methods that do I/O are written twice, sync and async, because in Python a function is either one or the other. That is a deliberate trade, and #3042 and the history above show its cost being paid.

Why not have the sync client call the async one?

It’s the obvious way to share the code: write it once as async, and have each sync method run it with asyncio.run(...). The catch is that asyncio.run starts a new event loop for every call, and refuses to run at all inside one that’s already running. A sync client built that way couldn’t be called from inside async code, and would pay for a loop per request.

Keeping a loop alive in a background thread avoids that, at the cost of threads and locking that the plain sync code never needed. That is another architecture to maintain. Two copies avoid all of it, and the price is the drift this post is about.

Generate one and check it. httpcore has the same problem and answers it differently. Its async code is the master, and scripts/unasync.py writes the sync copy from it by rewriting async def to def, deleting await, turning aclose into close, and so on. Run with --check, it writes nothing and exits with an error if any line of the sync files differs from what it would have written. The project’s scripts/check ends by running it. The copies stay separate, nobody edits the sync one by hand, and a missed update fails the check instead of shipping.

That third answer is the one I’d reach for more often than people do. On a media platform where I designed the developer toolchain, the agent surfaces, the way each AI agent runtime sees the toolchain, had to exist for three different runtimes, and we generated all three from one source definition. A generator like that is usually a small script, and a check that its outputs are current is one more step in CI. Unlike an abstraction, it doesn’t ask you to predict how the copies will change.

The check costs less than the generator

If generating the copy is more than you want, start with a test. Compare the two copies after normalising the differences you expect, such as await and the Async prefixes, much like httpcore’s substitution list, and fail if anything else differs. It doesn’t stop anyone editing one copy, but it stops the edit merging without the other. Normalise only the differences you intend to allow; stripping more just to make the test pass defeats it.

Formatting needs a fixed place in that order. httpcore’s scripts/lint formats first and runs the generator last, with a comment explaining why: “Run unasync last because its --check mode is not aware of code formatters.” If a formatter rewrote the generated file afterwards, the check would fail on whitespace.

Decide from the reason for the differences#

Put together, the decision isn’t about how many copies there are. “Enough history” is a judgement. The tool below calls fewer than five changes since both existed too young to tell, which is a practical cut-off rather than a statistical one. More history only helps if it really belongs to these functions and you have read what changed.

too young

yes

no

yes

yes

no

Two functions look alike

Enough history?

Keep both, add a check they match

Mostly changed together?

Leave them apart

Can callers share code?

Merge them

Generate one, fail the build on drift

Read “changed together” as a question about intent as well as commits: a formatting change that touched both says nothing about a shared rule.

What the history saysWhat it meansWhat to do
Too few changes since both existedYou can’t know yetKeep the copies. If they are meant to match, add a test or a comment that ties them together
Mostly together, and the one-sided changes were missesOne idea, written twiceMerge, if the callers can share code. If they can’t (sync and async, two languages, two services), generate and check
Mostly apart, and the one-sided changes were on purposeTwo ideas that look alikeLeave them, unless there is a smaller part they genuinely share. Merging builds the bilingual manual full of “English readers skip this” notes

I’ve had to make this kind of call at both ends. On a large communications platform, calling was implemented separately for each device platform and was heading towards eight parallel implementations. I set the strategy that converged them onto one write-once architecture. In a media contract across three operating systems, I drew the line the other way on purpose: a narrow portable interface where portability was achievable, and native code per OS where compositor and graphics differences made a shared abstraction unsound.

Counting copies didn’t decide either one. Both came down to where the platforms really agreed and where each needed room to change on its own, which is the question co-change asks of your history.

Try it on a pair you already suspect#

For a whole Python repository, the script lists every near-duplicate pair and clone group with its counts. For one pair, it lists every commit:

Shell
Terminal window
python3 cochange.py path/to/repo src
python3 cochange.py path/to/repo src --pair src/a.py:Client.get src/b.py:AsyncClient.get

For one pair you already suspect, in Python, JavaScript or TypeScript, or the C family of languages, there’s a browser tool. Get each copy’s history with its current first and last line numbers:

Shell
Terminal window
git log --no-patch --format='%h %cI %s' -L <first>,<last>:<file>

Paste the two functions and the two histories, and press Run. The tool leads with how many commits changed only one copy, draws the three counts as bars, and lists every one-sided commit, newest first, so you can read them. Below that are where the two differ today, line by line, which differ only in names and which in what they do, and how alike they are with names set aside. It reads the language from the code and compares bodies only, at the script’s 85%. The “Left out” line in its detail is the script’s two cuts added up: 22 commits of other code plus e107e0f makes 23.

Its first sample is the httpx request pair from this post, counted from e107e0f, and Run gives the 15 together, 1 sync-only and 6 async-only from above. Then clear Count from and run it again to watch the counts jump to 29 together and 15 one-sided (4 and 11). Ignore comes filled with 8e36f2b, the 29-file TLS change the script sets aside on its own. Clear that too and it counts as one more change to both, 30 together. Put e107e0f back in Count from, and you’re at 16 together, with only the TLS change added:

Copy drift/tools/copy-drift/

The tool reads tokens rather than fully parsing each language, and its one rule, that fewer than five changes is too early to tell, is stated rather than tuned. It can’t tell whether a one-sided change was a bug, whether a shared change did the same thing to both copies, or whether a commit swept the whole repository unless you name it in Ignore.

The script, its tests, the hand-checked sample and the CI that reruns all of it against httpx at a pinned commit are in premature-abstraction/.

httpx’s logging line took 23 days and a second contributor to reach its twin. So the next time someone in a review says “these three look the same, let’s pull them together”, don’t count them. Ask git how they’ve been changing, and read the changes that went to only one copy, because that is often where the design decision is already written down. If they move as one, merge them, or generate one from the other. If they don’t, they were never the same thing.

Share this article

Related posts

3 items
  • Aug 25, 2026 26 min read Systems

    Every latency number answers one question

    A checkout page hangs while the dashboard says 23 ms. The mean, a percentile and a share under a threshold each answer a different question, only two of them add up across hosts, and an averaged p99 can be wrong by a factor of three in either direction.

    The unwritten rulesobservabilityperformancestatistics
  • Oct 13, 2026 26 min read Software craft

    Code review economics

    On three busy repositories the wait for a first review grows much more slowly than a PR's size, so a change split into eight PRs reviewed one after another waits three to eight times as long. Opening them side by side helps less than it looks, and only one reviewer taking them together is no worse than one PR. The measurement, the rule that falls out of it, and a tool to run it on your own PRs.

    The unwritten rulescraftteamsstatistics
  • Aug 31, 2026 11 min read Software craft

    Keeping optional Python imports optional

    A test that checks importing a library leaves its optional dependency unloaded, and proves it got far enough to notice.

    The unwritten rulespythontesting
  • Systems

    Every latency number answers one question

    A checkout page hangs while the dashboard says 23 ms. The mean, a percentile and a share under a threshold each answer a different question, only two of them add up across hosts, and an averaged p99 can be wrong by a factor of three in either direction.

    observabilityperformance
    Aug 2026
  • Software craft

    Code review economics

    On three busy repositories the wait for a first review grows much more slowly than a PR's size, so a change split into eight PRs reviewed one after another waits three to eight times as long. Opening them side by side helps less than it looks, and only one reviewer taking them together is no worse than one PR. The measurement, the rule that falls out of it, and a tool to run it on your own PRs.

    craftteams
    Oct 2026
  • Software craft

    Keeping optional Python imports optional

    A test that checks importing a library leaves its optional dependency unloaded, and proves it got far enough to notice.

    pythontesting
    Aug 2026

Comments