Thirty-six modules, one sync from deletion
Thirty-six fork modules were declared in a template-owned file that syncs by overwrite. The seam built for exactly this was present, documented and empty.
A project scaffolded from a template declares its Gradle modules in the settings file, the way every Gradle project does. Thirty-nine feature modules were listed there. Three of them came from the template. The other thirty-six were ours.
That file is template-owned, and the template's sync replaces it wholesale.
The case this post is about
Two files, one of which the template overwrites:
settings.gradle.kts template-owned, overwritten wholesale on sync
settings.local.gradle.kts the seam, never written by the template — fork includes here
Measured on a real fork before the fix: 39 feature includes in the synced file — three of them the template's own demo modules, and 36 fork-level — against 0 in the seam.
Every one of those 36 was added by somebody doing the obvious thing, to the file that already had includes in it. One template sync deletes all 36, the build breaks, and not one fork-owned file shows a diff — because the file that changed is not fork-owned.
This post follows those 36 lines, because the fix has to prove it changed nothing, and inspection cannot do that.
The symptom
There is no symptom. That is the entire difficulty with this one.
The build works. The modules resolve. Every feature compiles, the app runs, the tests pass, and nothing in any output suggests that thirty-six module declarations are sitting in a file that a routine maintenance operation will overwrite without asking. The defect is purely latent: it is a property of where something is written, and nothing evaluates that property.

The second thing is what makes it so easy to get wrong. That file is the natural place to put a module. It is the file Gradle documents, the file every tutorial shows, and the file already containing thirty-eight other includes when you add the thirty-ninth. Everything about the context says you are doing the normal thing — because you are, in every project that is not scaffolded from a template.
The third is what the failure looks like when it eventually arrives. A sync runs, the file is restored to its template content, and the next build reports that a project cannot be found. Your feature's source is still on disk, untouched, exactly where it was. So the error reads as a Gradle problem, and the first hour goes into the build system rather than into the one line that disappeared.
And the fourth is the one that makes the diagnosis genuinely hard: no fork-owned file shows a diff. From the repository's point of view, a template-owned file was brought back into line with its source, which is the sync doing precisely its job. You cannot find the deletion by reviewing your own work, because it is not in your own work — a point this makes concrete in a way the general version of the argument does not.
Why the obvious fix failed
Stop syncing the template. Removes the hazard by removing the benefit, which is the same bad trade as always. The shared build wiring and module conventions are the reason the template exists, and freezing them to protect thirty-six include lines is paying a lot for a one-line problem.
Make the sync merge rather than overwrite. Superficially the obvious engineering answer and it is worse than it sounds. Merging a generated file means conflict resolution on every sync, in a file nobody reads carefully, where a bad merge produces a build that fails in a confusing place. Overwrite is not a limitation of the sync — it is the correct behaviour for a file whose content is owned elsewhere.
Put a comment at the top saying do not edit. It is already there. The file says what it is. That did not prevent thirty-six includes from being added, because the comment is at the top and people append at the bottom, and because a warning competes with the strong contextual signal that this is simply where includes go.
Back it up and restore after syncing. A manual step bolted onto an automated one, which is the checklist pattern with a different name. It works until the first sync somebody runs without remembering, and the whole point of a sync is that it is routine.
The actual mechanism
Use a seam: a second file that the template never writes, which the template-owned file includes.
The template-owned settings file applies the template's structure and then hands off to the seam. The sync can overwrite it as freely as it likes, because nothing of yours is in it. Your includes live in a file the sync has no opinion about.
settings.gradle.kts template-owned, overwritten wholesale on sync
settings.local.gradle.kts the seam, never written by the template — fork includes here
What makes this slightly humbling is that the seam already existed. The template ships it, the template-owned file already includes it, and the file itself carries a comment saying what it is for. This was not a missing mechanism. It was a present, documented, empty one, with thirty-six declarations sitting in the wrong file beside it.

The sub-case that stops this being a blunt rule: not everything in that file should move. Three of the thirty-nine includes are the template's own demo modules, and they belong exactly where they are, because the template re-supplies them on every sync. Moving them to the seam would mean maintaining by hand something that arrives for free, and would cause a duplicate-include failure the first time the template added one back.

So the classification is per-include rather than per-file: does the template supply this module, or do we? That question has a definite answer and it is the only thing you need to decide correctly.
Then there is proving the move, which deserves more care than it usually gets. Moving an include from one file to another either changes the resolved project graph or it does not, and looking at it is not evidence. The check is to capture the resolved set of projects before the change and after it and compare them exactly. If they are identical, the move is a no-op in every way that matters, which is the only outcome that makes a thirty-six-line structural change safe to take in one commit.
One thing that check must not do is tear down the build daemon to get a clean reading. A cold build is a different and much slower thing, and the warmth is not what is being tested — the project graph is the same whether the daemon is warm or not.
There is a reason to do the whole move in one commit rather than incrementally, and it is the same reason the graph check exists. A partial move leaves includes split across two files with no principle distinguishing them, which is strictly worse than having them all in the wrong place — at least a uniformly wrong file is legible. The intermediate state is the one where somebody later cannot tell whether an include is in the seam because it is fork-level or because somebody got halfway through an afternoon.
That is only tolerable because the change is provably inert. A structural edit you can verify as a no-op is one you can take all at once; one you cannot is a change you are obliged to stage, and staging it is what produces the ambiguous middle.
The fix
classify each include: template-supplied, or fork-level
move the fork-level ones into the seam
leave the template's own modules where they are
prove resolved project graph identical before and after
enqueue the upstream half — the settings file is template-derived
The classify step is the only one that needs judgement, and it is mechanical once stated: an include is template-supplied if the template's own settings file has it, and fork-level otherwise. That is a set difference, not an opinion:
#!/usr/bin/env bash
set -euo pipefail
# CLASSIFY — an include is the template's if the template's own file has it, ours otherwise.
tpl=$(git show upstream/dev:settings.gradle.kts | grep -oE 'include\("[^"]+"\)' | sort)
ours=$(grep -oE 'include\("[^"]+"\)' settings.gradle.kts | sort)
fork=$(comm -13 <(echo "$tpl") <(echo "$ours")) # ours and NOT the template's
echo "$fork" > /tmp/fork-level.txt
# PROVE (before) — the resolved graph, not the file. This is the baseline the move must preserve.
before=$(./gradlew projects --quiet)
# MOVE — fork-level includes out of the synced file, into the seam the sync never overwrites.
grep -vFf /tmp/fork-level.txt settings.gradle.kts > settings.gradle.kts.tmp
mv settings.gradle.kts.tmp settings.gradle.kts
echo "$fork" >> settings.local.gradle.kts
# PROVE (after) — identical graph, or the move was wrong and this exits non-zero.
after=$(./gradlew projects --quiet)
diff <(echo "$before") <(echo "$after") && echo "graph identical — move is safe"
Run on a fork with 39 feature includes in the synced file:
$ wc -l /tmp/fork-level.txt
36 # 3 of the 39 are the template's demo modules
$ diff /tmp/graph-before.txt /tmp/graph-after.txt && echo "graph identical"
graph identical
Note what the proof is not: it is not "the build still succeeds". A build succeeding proves the graph is valid, not that it is the same graph — drop an include and the build is perfectly happy about a module that no longer exists. Comparing the resolved project list is what catches that, and it costs two commands.
The last line follows from the file's ownership. If the template-owned settings file needs any change at all to make this work cleanly, that change belongs upstream rather than in a local edit — because a local edit to a template-owned file is the exact failure this post is about, applied to the fix for itself.
How we proved it
The counts are the evidence and they are specific. Thirty-nine feature includes in the template-owned settings file. Three of them template-supplied demo modules. Thirty-six fork-level. Zero in the seam. That last number is what turns this from a design preference into a finding — the mechanism was shipped, documented and entirely unused.
The move was verified by graph comparison, not by reading the diff. The resolved set of Gradle projects before the change and after it is identical, which is the property that actually matters and the only one that could not have been established by inspection. A thirty-six-line change to module declarations is exactly the kind of edit where "it looks right" and "it is right" come apart.
The three-versus-thirty-six split is itself a useful finding. It says the template's own contribution to that file is small and stable, and that almost everything accumulating in it over the project's life was fork-level. That ratio is what makes the seam the right shape of answer: if the numbers had been reversed — thirty-six template modules and three of ours — a seam would be overkill and simply remembering three lines would be reasonable. The mechanism is justified by the measurement rather than by the principle.
What I cannot show you is the deletion, because it has not happened. Everything above is a hazard that was found and closed before it fired, which means the central claim — that a sync would have removed thirty-six includes and left no fork-owned diff — rests on the sync's documented behaviour rather than on an incident. I believe it because the file's provenance and the sync's mechanism are both plain, and I would rather say that than imply I watched it happen.
What I have not solved: detection for the next one. Nothing checks, on an ongoing basis, whether a fork-level include has appeared in the template-owned file again. The next person to add a module — reasonably, in the documented place, following every surrounding example — recreates the problem, and the only thing standing against it is that the file now has fewer examples of the wrong pattern in it.

And a limitation worth being plain about: the seam only helps for the thing it was built for. Settings is one of several template-owned files, and the same analysis applies to each of them separately — some have seams, some do not, and I have checked one. The finding here is a specific instance of a general hazard, and the general version remains open.
What this generalizes to
In any project built from a template, a generator or a vendoring step, the file that feels like the natural place to put something is often the file that is not yours.
The reason is not coincidence. Generated and template-owned files are the structural ones — the build configuration, the module declarations, the dependency wiring — and structural files are exactly where new structure gets added. The hazard concentrates precisely where the ordinary work happens, which is why discipline fails against it and why the comment at the top of the file did not help.
So the useful move is to find the seam, and if there is not one, make one: a file the generator never writes, included from the one it does. Then the question "is this mine or theirs" is answered by which file you are typing in, rather than by remembering a provenance rule at the moment you are thinking about something else entirely.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".