Stop cross-file drift with Google's IfChange / ThenChange comments.
Open-source reimplementation of Google's internal IfThisThenThat linter.
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 the case where someone edits one of those places manually and bypasses the release flow.
on:
push:
branches: [main]
pull_request:
jobs:
ifttt-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: simonepri/ifttt-lint@v0.10.0The action mirrors the two hooks:
pull_requestβ diff validation equivalent toifttt-lint-diff. Validates co-changes across all commits in the PR. SupportsNO_IFTTTsuppression via commit messages.pushβ structural validation on all tracked files, equivalent toifttt-lint '**/*'. Useon.push.branchesto control which branches run it.
- repo: https://github.com/simonepri/ifttt-lint
rev: v0.10.0
hooks:
- id: ifttt-lint
- id: ifttt-lint-diffTwo hooks serve different purposes:
-
ifttt-lintβ runs at every commit on the staged files. Checks that allThenChangetargets and labels exist on disk, directives are properly paired, and syntax is valid. Also supportspre-commit run --all-filesfor full-repo structural scans. -
ifttt-lint-diffβ runs at every push on all files in the diff range. Checks that co-dependent files are updated together. SupportsNO_IFTTTsuppression via commit messages. Mirrors thepull_requestGitHub Actions check in intent: diff-based validation with the same suppression mechanism, though the exact git range differs by context.
If you prefer running ifttt-lint directly, install it with Cargo:
cargo install ifttt-lintSee the CLI reference below for invocation patterns and flags.
Guidance for AGENTS.md sits on top of any of the install paths above β it doesn't replace them. If you use coding agents (Codex, Claude Code, etc.), consider adding something like this to your AGENTS.md so agents create directives proactively and keep them narrow:
#### Co-dependent changes: use IFTTT directives
When code in one place must stay in sync with code elsewhere β but DRY cannot eliminate the duplication (for example, cross-language boundaries, config mirroring code, or encode/decode pairs) β mark the dependency with `LINT.IfChange` / `LINT.ThenChange` directives so changes to one side prompt review of the other. Add these directives proactively when creating new co-dependent content, not just when maintaining existing pairs.
Keep the guarded block as small as possible. Prefer several small labeled source->target pairs over one large catch-all block unless the whole region genuinely needs to change together. The directives can be validated and enforced via [ifttt-lint](https://github.com/simonepri/ifttt-lint/blob/main/readme.md).
<details>
<summary>Example</summary>
```javascript
// LINT.IfChange(speed_threshold)
SPEED_THRESHOLD_MPH = 88;
// LINT.ThenChange(
// //db/migrations/temporal_displacement.sql,
// //docs/delorean.md:speed_threshold,
// )
```
</details>Add directives as comments in any supported language β the tool auto-detects comment styles based on file extension.
Your upload limit is defined in code and referenced in the API docs. Label both sides and link them β if one changes, the other must too:
config/upload.py |
docs/api.md |
|---|---|
# LINT.IfChange(upload_limit)
MAX_UPLOAD_SIZE_MB = 50
# LINT.ThenChange(//docs/api.md:upload_limit) |
<!-- LINT.IfChange(upload_limit) -->
Files up to 50 MB are accepted.
<!-- LINT.ThenChange(//config/upload.py:upload_limit) --> |
Bump the limit to 100 MB but forget the docs? The linter catches it:
config/upload.py:1: warning: changes in this block may need to be reflected in docs/api.md:upload_limit
When types cross language boundaries, a shared schema language (Protocol Buffers, Thrift, GraphQL) is the best solution. But not every project uses one β and even when it does, hand-written types often exist alongside generated ones. For those cases, link the two sides directly:
api/types.go |
web/src/types.ts |
|---|---|
// LINT.IfChange(user_response)
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// LINT.ThenChange(//web/src/types.ts:user_response) |
// LINT.IfChange(user_response)
interface UserResponse {
id: string;
name: string;
email: string;
}
// LINT.ThenChange(//api/types.go:user_response) |
A rate limit touches the database, the docs, and an alerting threshold in the same file. List all dependents β the tool checks every target:
# LINT.IfChange
RATE_LIMIT_RPS = 100
# LINT.ThenChange(
# //db/migrations/002_rate_limits.sql,
# //docs/api.md:rate_limits,
# :alert_threshold,
# )Serialize and deserialize must stay in lockstep β use :label to reference another section in the same file:
# LINT.IfChange(serialize_event)
def serialize_event(event: Event) -> bytes: ...
# LINT.ThenChange(:deserialize_event)
# LINT.IfChange(deserialize_event)
def deserialize_event(data: bytes) -> Event: ...
# LINT.ThenChange(:serialize_event)ifttt-lint implements Google's LINT.IfChange / LINT.ThenChange directive syntaxβ .
| Directive | Description |
|---|---|
LINT.IfChange |
Marks the start of a watched region |
LINT.IfChange(label) |
Watched region with a named label (targetable from other files) |
LINT.ThenChange(//path) |
End of watched region; requires target file to be modified |
LINT.ThenChange(//path:label) |
Requires changes within a specific label range in the target |
LINT.ThenChange(:label) |
Same-file label reference |
LINT.ThenChange(//a, //b) |
Multiple targets (comma-separated) |
β ifttt-lint enforces stricter path rules than Google's internal linter by default β use --strict=false for Google-compatible behavior.
- All file paths must start with
//(project-root-relative) :separates file path from label (splits on last:) β Windows drive-letter colons (e.g.C:\) are not treated as label separators:labelalone means same-file reference
Use --strict=false for Google-compatible behavior β bare paths (path/to/file), single-/ paths (/path/to/file), and explicit same-file path references (//same-file.h:label instead of :label) are all accepted without warnings.
Labels must start with a letter, followed by letters, digits, underscores, dashes, or dots. For example: upload_limit, user-response, section2, Payments.Pix.Result.
Directives must appear alone on their comment line β no extra text before or after the directive pattern. If a comment contains a directive-like pattern with trailing text (e.g. // LINT.IfChange(label) see docs or // LINT.ThenChange(//path) -->), it is silently ignored as prose. This avoids false positives from documentation or comments that mention directive syntax without intending to create a directive.
Nesting IfChange blocks is not supported β each IfChange must be closed by a ThenChange before another can begin. A consecutive IfChange without an intervening ThenChange is reported as an error. Nesting could be added (stack-based pairing) but lacks a clear use case: any nested scenario decomposes into sequential blocks with multiple targets.
Directives use the supported comment syntax for each file extension β //, #, <!-- -->, --, ;, %, and /* */ all work depending on the language. See supported languages for the full registry.
-- LINT.IfChange(schema)
CREATE TABLE users (id UUID, name TEXT, email TEXT);
-- LINT.ThenChange(//api/types.go:user_response)# LINT.IfChange(deploy_config)
replicas: 3
# LINT.ThenChange(//docs/runbook.md:scaling)Directives inside fenced Markdown code blocks (```) are ignored β the linter won't fire on examples in documentation. This README itself contains dozens of LINT.IfChange examples and passes ifttt-lint cleanly. The same applies to code blocks inside doc comments (Rust ///, Python docstrings with embedded examples).
ifttt-lint [OPTIONS] [FILES]...
| Argument | Description |
|---|---|
FILES... |
Files to validate structurally: checks that every ThenChange target and label exists on disk, regardless of whether the file was modified. Supports glob patterns resolved internally via git ls-files to avoid shell ARG_MAX limits. * matches within a single directory level; use **/* for recursive matching (e.g. '**/*.rs'). |
| Option | Description |
|---|---|
-d, --diff <RANGE> |
Git ref range to diff (e.g. main...HEAD) |
-t, --threads <N> |
Worker thread count (default: 2; 0 = same as 2) |
-i, --ignore <PATTERN> |
Permanently ignore target pattern, repeatable (glob syntax) |
--strict=false |
Accept bare and single-/ paths in ThenChange targets (in addition to //). Required for codebases that use Google-internal path conventions |
-f, --format <FMT> |
Output format: pretty (default), json, plain |
| Exit Code | Meaning |
|---|---|
0 |
No errors |
1 |
Lint errors found |
2 |
Fatal error (bad diff, I/O failure) |
ifttt-lint runs up to three validation passes depending on how it's invoked. The default pretty output format uses standard file:line: severity: message syntax, compatible with most editors and CI systems.
| Invocation | Hook stage | What runs |
|---|---|---|
ifttt-lint (no args) |
β | Nothing β exits 0 with a hint |
ifttt-lint FILES⦠(no --diff) |
pre-commit |
Structural validation on listed files only |
ifttt-lint '**/*' (no --diff) |
CI, manual | Structural validation on all tracked files (glob expanded via git ls-files) |
ifttt-lint --diff REF FILES⦠|
β | Structural validation on listed files. Diff validation scoped to listed files. Reverse lookup for deleted files and stale labels |
ifttt-lint --diff REF (no files) |
pre-push |
Structural + diff validation on all files in the diff. Reverse lookup for deleted files and stale labels |
When an IfChangeβ¦ThenChange block is present in a changed file, the tool checks whether the guarded content (lines between the directives) was modified. If it was, every ThenChange target must also show changes in the same diff β otherwise a finding is reported.
Changes to the directive lines themselves (adding a new pair, renaming a label, adding or removing a ThenChange target) do not trigger validation β only content between the directives matters. The tool does not try to infer semantic code moves; it validates the file, label, and path coordinates visible in the diff.
Fires:
A field is added to the Go struct but the TypeScript mirror is not updated:
// LINT.IfChange(user_response)
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
+ Avatar string `json:"avatar"`
}
// LINT.ThenChange(//web/src/types.ts:user_response)api/types.go:1: warning: changes in this block may need to be reflected in web/src/types.ts:user_response
Content is modified and a new target is added in the same diff β all targets (including the new one) must reflect the change, because you're declaring a dependency while simultaneously changing the content it guards:
// LINT.IfChange(upload_limit)
- MAX_UPLOAD_SIZE_MB = 50
+ MAX_UPLOAD_SIZE_MB = 100
- // LINT.ThenChange(//docs/api.md:upload_limit)
+ // LINT.ThenChange(
+ // //docs/api.md:upload_limit,
+ // //alerts/thresholds.yaml:upload_limit,
+ // )config/upload.py:1: warning: changes in this block may need to be reflected in docs/api.md:upload_limit
config/upload.py:1: warning: changes in this block may need to be reflected in alerts/thresholds.yaml:upload_limit
Does not fire:
Adding a new directive pair around existing code β the directive is being established, not the content changed:
+ // LINT.IfChange(speed_threshold)
SPEED_THRESHOLD_MPH = 88
+ // LINT.ThenChange(//docs/delorean.md:speed_threshold)Adding a new target to an existing directive β directive metadata changed, not guarded content:
// LINT.IfChange(rate_limit)
RATE_LIMIT_RPS = 100
- // LINT.ThenChange(//docs/api.md:rate_limits)
+ // LINT.ThenChange(
+ // //docs/api.md:rate_limits,
+ // //alerts/thresholds.yaml:rate_limits,
+ // )Renaming a label β directive metadata changed; stale references are caught by the reverse lookup instead:
- // LINT.IfChange(old_name)
+ // LINT.IfChange(new_name)
SPEED_THRESHOLD_MPH = 88
// LINT.ThenChange(//docs/delorean.md:speed_threshold)Label renamed and content changed in the same commit β a known edge case. The tool sees this as a full delete + add (the whole IfChange(old_name) block disappears, a new IfChange(new_name) block appears), so the content change is not flagged for diff-based validation:
- // LINT.IfChange(old_name)
- MAX_UPLOAD_SIZE_MB = 50
+ // LINT.IfChange(new_name)
+ MAX_UPLOAD_SIZE_MB = 100
// LINT.ThenChange(//docs/api.md:upload_limit)If you need both a rename and a content change, split them across two commits so the second one triggers the sync check. Stale references to the old label are still caught by reverse lookup in the meantime.
Both sides updated in the same diff β the target already reflects the change:
// LINT.IfChange(upload_limit)
- MAX_UPLOAD_SIZE_MB = 50
+ MAX_UPLOAD_SIZE_MB = 100
// LINT.ThenChange(//docs/api.md:upload_limit) <!-- LINT.IfChange(upload_limit) -->
- Files up to 50 MB are accepted.
+ Files up to 100 MB are accepted.
<!-- LINT.ThenChange(//config/upload.py:upload_limit) -->Suppressed via NO_IFTTT in the commit message β explicitly opted out (see Suppression below).
When files are passed as positional arguments (FILESβ¦), the tool checks directive structure regardless of the diff. This catches issues that diff-based validation can't see β broken references, missing targets, malformed syntax.
| Check | Example message |
|---|---|
| ThenChange target file doesn't exist | target file not found: web/src/old_types.ts |
| ThenChange label not found in target | label upload_limit not found in docs/api.md |
| IfChange without matching ThenChange | LINT.IfChange without matching ThenChange |
| ThenChange without preceding IfChange | LINT.ThenChange without preceding IfChange |
| Duplicate IfChange labels in same file | duplicate LINT.IfChange label foo |
Reverse lookup covers stale references that diff-based validation cannot see:
- When a target file is deleted or renamed, surviving
ThenChange(//old/path)references are reported as stale old paths. - When a file is modified and an
IfChangelabel disappears from that file's current contents, survivingThenChange(//path:old_label)references are reported as stale old labels. This includes label renames, removals, and moving the labeled block elsewhere, but it is still based on the old file+label coordinate rather than semantic move tracking.
api/types.go:7: warning: target file not found: web/src/old_types.ts
config/upload.py:3: warning: label old_name not found in constants.py
Reverse lookup always runs globally β it is not scoped by the file list.
When you intentionally skip diff-based ThenChange checks for a commit, add NO_IFTTT=<reason> to the commit message:
feat: raise upload limit to 100 MB
NO_IFTTT=docs will be updated in a follow-up
NO_IFTTT=<reason> in any commit message in the scanned range suppresses diff-based validation for the entire range. Structural validation and deleted-file reverse lookup always run regardless of NO_IFTTT. The tag has no effect without --diff.
Scope β each context scans exactly one range:
| Context | Diff range | Commit messages scanned |
|---|---|---|
| pre-push hook | FROM_REF..TO_REF (all unpushed commits) |
All unpushed commits |
| Pull request (CI) | BASE_SHA...HEAD_SHA (merge-base to PR head) |
All commits in the PR |
| Push to main (squash merge, CI) | BEFORE..HEAD (1 commit) |
That squashed commit |
| Push to main (rebase merge, N, CI) | BEFORE..HEAD (all N commits) |
All N commits |
| Push to main (merge commit, CI) | BEFORE..HEAD (merge + PR branch commits) |
Merge commit and all PR branch commits |
To permanently ignore targets, use --ignore:
ifttt-lint --ignore "generated/**" --ignore "*.lock"Comment style is detected by file extension. The full language registry with skip-pattern documentation lives in src/languages.rs β 43 entries covering many common file extensions.
| Style | Languages |
|---|---|
// /* |
C/C++, C#, Dart, Go, Groovy, Java, JavaScript, Kotlin, Objective-Cβ , Protobuf, Rust, Scala, SCSS, Swift, TypeScript |
# |
CMake, Dockerfile, Elixir, GN, GraphQL, Makefile, Nix, Perl, PowerShell, Python, R, Ruby, Shell, Starlark, Terraform, TOML, YAML |
<!-- --> |
HTML, Markdown, XML |
-- |
Haskell, Lua, SQL |
; |
Lisp / Clojure |
% |
LaTeX |
/* */ |
CSS |
Multi-syntax: Vue/Svelte (//, /*, <!--), PHP (//, /*, #), Terraform (#, //, /*).
Unknown extensions fall back to //, /*, #.
β .m files are shared by Objective-C (//) and MATLAB (%). Both comment prefixes are accepted so directives work in either language.
Note: Directives are recognized only on single comment lines β either line comments (
//,#, etc.) or block comments used on a single line (/* ... */,<!-- ... -->). Multi-line block comments spanning several lines are not scanned, which avoids false positives from commented-out blocks.
The LINT.IfChange / LINT.ThenChange pattern originated inside Google and is documented publicly in Chromium's developer guide; Chromium, TensorFlow, Fuchsia, and other Google codebases use it at scale (Chromium alone has well over a thousand LINT.IfChange directives in tree). This repo is an independent open-source reimplementation β not affiliated with or endorsed by Google β that follows the same directive syntax and semantics, so existing Google guidance applies, but shares no code with Google's internal tooling. Some edge behavior differs (e.g. stricter path rules by default); use --strict=false for Google-compatible behavior.
When you can, you absolutely should. IfChange/ThenChange is for the gaps that remain β code β prose docs, code β config, hand-written types alongside generated ones, encode/decode pairs β where a shared schema either doesn't cover the boundary or costs more to introduce than the duplication it removes.
Yes. ifttt-lint is designed so the common case stays local and the worst case stays filtered β the linter reads only the changed files plus referenced targets. A single git grep runs when a target file is deleted or a label is renamed, narrowed by cheap literal filters and respecting .gitignore.
Real-world (structural validation, M-series MacBook, 2 threads):
| Repository | Tracked files | Files with directives | Time |
|---|---|---|---|
| Chromium | 488k (~3.9GB) | 1.7k (~39MB) | 0.9 s |
| TensorFlow | 36k (~402MB) | 244 (~5.3MB) | 0.2 s |
Default thread count is 2 (--threads 0); higher counts hit filesystem I/O contention and don't help. Reproduce with cargo smoke.
Yes β that's the primary use case. Directives work across any file types in the supported languages table. Paths are project-root-relative (//), so they work regardless of where the files live in the tree.
No β paths are project-root-relative (//), so all linked files must live in the same repository. Cross-repo dependencies are a fundamentally harder problem (versioning, release cadence, ownership boundaries) that a comment directive can't solve. If you need cross-repo coordination, consider shared packages with versioned contracts, or a schema registry. If you have ideas on how cross-repo support could work, open an issue.
Currently only Git is supported. The core validation logic is VCS-agnostic β all VCS operations (diffs, file reads, file search) go through a VcsProvider trait, and Git is the only implemented backend (src/vcs_git.rs). Adding Mercurial, Perforce, or another VCS means implementing that trait β no changes to the validation engine are needed. PRs welcome; open an issue to discuss.
Yes, please contribute! For many languages, adding support is just a new entry in the comment-style table. Some languages may need a new skip pattern if their string or comment syntax is unusual. PRs welcome; open an issue if you're unsure about the comment syntax.
if-changed, ifttt-lint, and ifchange exist but use different syntax and/or aren't validated on large-scale repos. For background on the pattern, see IfChange/ThenChange, Syncing Code, and Fuchsia presubmit checks.
