Skip to content

simonepri/ifttt-lint

Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

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.

crates.io license

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 the case where someone edits one of those places manually and bypasses the release flow.

Setup

GitHub Actions

on:
  push:
    branches: [main]
  pull_request:

jobs:
  ifttt-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: simonepri/ifttt-lint@v0.10.0

The action mirrors the two hooks:

  • pull_request β€” diff validation equivalent to ifttt-lint-diff. Validates co-changes across all commits in the PR. Supports NO_IFTTT suppression via commit messages.
  • push β€” structural validation on all tracked files, equivalent to ifttt-lint '**/*'. Use on.push.branches to control which branches run it.

pre-commit (recommended)

- repo: https://github.com/simonepri/ifttt-lint
  rev: v0.10.0
  hooks:
    - id: ifttt-lint
    - id: ifttt-lint-diff

Two hooks serve different purposes:

  • ifttt-lint β€” runs at every commit on the staged files. Checks that all ThenChange targets and labels exist on disk, directives are properly paired, and syntax is valid. Also supports pre-commit run --all-files for 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. Supports NO_IFTTT suppression via commit messages. Mirrors the pull_request GitHub Actions check in intent: diff-based validation with the same suppression mechanism, though the exact git range differs by context.

Install the CLI manually

If you prefer running ifttt-lint directly, install it with Cargo:

cargo install ifttt-lint

See the CLI reference below for invocation patterns and flags.

Using with coding agents

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>

Usage

Add directives as comments in any supported language β€” the tool auto-detects comment styles based on file extension.

Keep code and docs in sync

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

Sync across language boundaries

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)

Link multiple targets

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,
# )

Sync within a file

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)

Reference

Directive syntax

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.

Path rules

  • 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
  • :label alone 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.

Label format

Labels must start with a letter, followed by letters, digits, underscores, dashes, or dots. For example: upload_limit, user-response, section2, Payments.Pix.Result.

Exact matching

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

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.

Comment syntax

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)

Fenced code blocks

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).

CLI reference

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)

Validation

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.

CLI modes

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

Diff-based validation

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).

Structural validation

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

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 IfChange label disappears from that file's current contents, surviving ThenChange(//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.

Suppression

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"

Supported languages

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.

FAQ

How does this relate to Google's internal linter?

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.

Why not use types, codegen, or a shared schema?

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.

Is this fast enough for large codebases?

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.

Can I use this in a monorepo with multiple languages?

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.

Does it work across repositories?

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.

Does it work with Mercurial, Perforce, or other VCS?

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.

My language isn't in the supported list β€” can I add it?

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.

Are there other implementations?

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.

About

πŸ”— Stop cross-file drift with Google's IfChange/ThenChange comments

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages