Before I start pitching you anything, let me do what any good salesperson would do: let's get personal.
If you have a terminal and a repo you know handy, run this:
git grep -iE 'keep.* in sync|stays? in sync|also update|must (also )?be updated'
The number you get back is what this whole post is about.
Reading on your phone? See what a similar search turns up in the Linux kernel and/or React.
If the grep lit up
Welcome. You already have this problem — informally, in prose, scattered across comments your contributors wrote when they wanted the next person to know "if you change this, also update that." The intent is there. The enforcement isn't. A comment asking the next contributor to remember isn't the same as a check that fails when they don't.
If the grep came back empty
Good for you — though an empty grep can mean two different things. Maybe your codebase genuinely doesn't have these couplings: small scope, contained architecture, or shared abstractions wherever duplication would otherwise appear. Or — more commonly — the couplings do exist, but nobody's written them down. The bug is just as possible; it's only less visible until someone trips over it.
Step outside your repo and the same pattern is everywhere. "keep this in sync with" — about as unambiguous a sync directive as English gets — turns up in over 90,000 public code files. Add "please also update", "must be kept in sync", and similar variations and the count climbs into the hundreds of thousands. The intent is universal. The enforcement isn't. Everywhere you look, people are writing prose that declares which files should move together; almost nowhere is anything actually enforcing that declaration.
Closing that gap — between "I declared the coupling" and "the build catches me when I break it" — takes two things: (1) a shared convention for marking the coupling explicitly, and (2) an enforcement layer that fails the build when someone ignores it.
(1) already exists and it's called IfThisThenThat — though if you haven't worked at Google (or read this article from Filiph Hracek), you've probably never heard of it. Which is why this post aims at two things: giving you a primer on (1) and introducing you to a new tool that handles (2).
IfThisThenThat: the pattern
At Google there's a linter/convention called IfThisThenThat — often abbreviated just as IFTTT. The idea is simple, you mark two regions (across files or within one file) with a structured comment and semantically declare that changes to one require changes to the other. The linter runs at pre-submit (roughly Google's equivalent of a pre-push hook) and blocks the change if one side moved and the other didn't.
The linter is internal to Google, but the idea/syntax it's not. The pattern is documented publicly in Chromium's developer guide, and Chromium's own tree has over 1,100 LINT.IfChange directives in use today. TensorFlow and Fuchsia use it too.
The directives look like this. Take a Kubernetes setup: your node sizes live in Terraform, your app's resource limits live in Helm, and they have to match by design — the pod has to fit the node.
resource "google_container_node_pool" "default" {
node_config {
# LINT.IfChange(node_size)
machine_type = "n2-standard-4" # 4 vCPU, 16 GB
# LINT.ThenChange(//charts/app/values.yaml:resource_limits)
}
}
resources:
# LINT.IfChange(resource_limits)
requests:
cpu: "2"
memory: "8Gi"
limits:
cpu: "3"
memory: "14Gi" # must fit within node machine_type
# LINT.ThenChange(//terraform/gke.tf:node_size)
Bump the Terraform node size to squeeze costs and forget the Helm side, and the linter fires:
terraform/gke.tf:1: warning: changes in this block may need to be reflected in charts/app/values.yaml:resource_limits
Without that catch, the next deploy hits FailedScheduling because the pod limits still assume the old node.
You might wonder what happens if you genuinely need to change one side without the other — say, bumping the Helm limits.memory from 14Gi to 15Gi when the value still fits inside the existing node capacity. For those, NO_IFTTT=<reason> in the commit message tells the linter to skip the check.
That's the whole mechanism. No AST magic, no schema system, no attempt to infer architecture. Two comments, one rule, one escape-hatch: if this block changes, that one must too — unless you say otherwise.
The only problem? Google has never released the linter behind the pattern to the public. Which stands out — Google is otherwise known for open-sourcing similar internal tools (like keep-sorted); this one just stayed inside.
ifttt-lint: the enforcement
I'm a strong believer that conventions without enforcement aren't useful. That's why I built ifttt-lint — an open-source Rust reimplementation of Google's internal linter, with the same directive syntax and semantics.1
simonepri
/
ifttt-lint
🔗 Stop cross-file drift with Google's IfChange/ThenChange comments
Stop cross-file drift with Google's IfChange / ThenChange comments.
Open-source reimplementation of Google's internal IfThisThenThat linter
The Problem
You add a field to a Go struct and forget the TypeScript mirror. You bump a constant and forget the docs. You rename a database column and forget the migration. You only discover it when something breaks in production — or worse, when a user reports it weeks later.
ifttt-lint is built to catch exactly that. You wrap co-dependent sections in LINT.IfChange / LINT.ThenChange comment directives. When a diff touches one side but not the other, the tool fails — before the change reaches production. The model is intentionally simple, which keeps it predictable.
This repo dogfoods its own directives to keep the tool version in sync across Cargo.toml, the pre-commit config, and the CI release pipeline. Automation (release-plz) does the normal sync; the directives catch…
Install it as a GitHub Action, a pre-commit hook, or via cargo install ifttt-lint, and you can start using the pattern in your repo today. Copy-pasteable configs live in the README's setup section.
If you're worried about performance, don't be — ifttt-lint validates Chromium's 488k-file tree in under a second on an M-series MacBook, which makes it comfortable as a pre-commit hook on any realistic codebase you'll ever touch. Forty-plus languages are supported out of the box — C, C++, Go, Rust, Java, Kotlin, Swift, TypeScript, Python, Ruby, Shell, YAML, Terraform, SQL, Markdown, and the rest of the usual suspects — and unknown extensions fall back to // / /* / #.
Under the hood it runs three validation passes in a single go:
-
Diff-based — "you touched the
IfChangeblock but not theThenChangetarget." The core check; this is what runs on every PR, and it's the oneNO_IFTTT=<reason>skips. -
Structural — "your
ThenChangepoints at a file or label that doesn't exist." Catches typos, renames, and dead references at commit time, before a reviewer has to. Always runs, even whenNO_IFTTTis set — you can't accidentally ship a reference to a file that doesn't exist. - Reverse-lookup — "you renamed a label and left surviving stale references elsewhere." Without this pass, stale references quietly accumulate and the directives themselves turn into the maintenance burden they were meant to prevent.
My intuition tells me this pattern will become more valuable, not less, as more code gets written by AI. Coding agents routinely introduce cross-file couplings while generating code — a constant duplicated in two places, a struct that mirrors a DB row, a value the agent decided should live in both code and config — and the human operator often doesn't notice. Drop the AGENTS.md snippet from the README into your repo, and your coding agent will add LINT.IfChange directives whenever it produces new co-dependent code. The linter then catches the next change that breaks them, regardless of who (or what) made it.
Are there any gotchas? One worth naming: code moves aren't tracked semantically. If you move a block from one file to another and change its content in the same commit, ifttt-lint sees a delete and an add — not "moved and modified" — so ThenChange targets aren't re-evaluated against the new location. When this matters, prefer splitting the change in two: the move first, the content edit after.
Wait — isn't this just papering over bad code?
Fair question. Generally counter-arguments against the existence of this pattern are right: a shared schema (Protocol Buffers, Thrift, GraphQL) really does eliminate cross-language drift when you can use one. Codegen really does make the coupling disappear when a single generator owns both sides. Types and constants really do solve the problem within a single language. When any of these apply, reach for them first — not for LINT.IfChange.
The catch is practical, not philosophical. Replatforming a Terraform ↔ Helm boundary onto cdk8s or Pulumi is weeks/months of work measured against two comment directives. Introducing a schema registry for one cross-language type shared between two services is a new piece of infrastructure to own. And some couplings — a constant and the English sentence describing it in the docs, a Prometheus alert threshold and the rate limit in application code, an encoder and its decoder, a migration and the struct that reads from it — don't have a clean "proper" fix at all; they're structural by design.
Those 19,000+ "keep in sync" comments scattered across open source are evidence that engineers already know when the pure fix isn't worth it; they just don't have anything backing the intent. The linter is the missing half.
Your turn
If you've read this far, you've probably already thought of at least one place in your own code that could use a directive. Drop the best (or worst) example in the comments — the flavors I haven't seen are the ones I most want to hear about.
And — in the spirit of the pattern itself — if this tool looks useful, then ⭐ the repo. It's the fastest signal that this kind of tool is worth building/maintaining.
-
I no longer work at Google.
ifttt-lintis an independent project, not affiliated with or endorsed by Google in any way, and shares no code with the internal version. ↩

Top comments (0)