A fast linter for enforcing conditional change directives in source code
Project description
ifchange
Lint for cross-file dependencies. Rename an env var in your deploy config, forget the code that reads it? ifchange catches it in the diff. 128 file extensions, 50+ languages. Robust and fast.
How it works:
- Mark related code sections with
LINT.IfChange/LINT.ThenChangecomments. - When a guarded section changes in a PR or commit, every referenced file must change too, or the build fails.
Rust implementation of Google's IfThisThenThat (IFTTT) linting pattern. TypeScript implementation: ebrevdo/ifttt-lint.
Install · Usage · Directive Syntax · CI / Automation · Performance · Supported Languages
Install
curl -fsSL https://raw.githubusercontent.com/slnc/ifchange/main/install.sh | sh
cargo install ifchange # Rust / crates.io
npm install -g @slnc/ifchange # Node.js / npm
pip install ifchange # Python / PyPI
Pre-built binaries for Linux, macOS, and Windows available on GitHub Releases.
Build from source:
cargo install --path .
Usage
1. Fence related sections with directives:
# deploy/app.yml
# LINT.IfChange
env:
DATABASE_URL: postgres://prod:5432/myapp
REDIS_URL: redis://prod:6379
# LINT.ThenChange(src/config.py#env)
# src/config.py
# LINT.Label(env)
DATABASE_URL = os.environ["DATABASE_URL"]
REDIS_URL = os.environ["REDIS_URL"]
# LINT.EndLabel
2. Rename an env var in the YAML, forget to update config.py, run ifchange:
git diff HEAD~1 | ifchange
error: deploy/app.yml:2 -> src/config.py#env: target section has no matching changes in diff
found 1 error (1 lint)
You can wire this into a pre-commit hook or CI action to run automatically.
3. More options:
ifchange changes.diff # pass a file
ifchange --no-lint # scan only: validate directive syntax
ifchange --no-lint -s ./src # scan a specific directory
git diff HEAD~1 | ifchange --no-scan # lint only: skip syntax scan
ifchange -i '**/*.sql' -i 'config.toml#db' f.diff # ignore files or labeled sections
--ignore uses glob patterns (*, ?, **) and matches both full relative paths and basenames.
| Flag | Description |
|---|---|
-w, --warn |
Warn instead of failing (exit 0) |
-v, --verbose |
Show processing details and validation summary |
-j, --jobs <N> |
Thread count (0 = auto) |
-i, --ignore <pattern> |
Ignore path glob or path-glob#label (repeatable) |
-s, --scan <dir> |
Scan directory for directive errors (default: .) |
--no-scan |
Skip directive syntax scan |
--no-lint |
Skip diff-based lint |
Exit codes: 0 ok, 1 lint errors, 2 fatal error.
Directive Syntax
Directives go at the start of a comment line. Full syntax reference: docs/DIRECTIVES.md.
LINT.IfChange / LINT.ThenChange
IfChange opens a guarded section. ThenChange closes it and lists the files that must co-change.
Simplest case, whole-file target:
# deploy/app.yml
# LINT.IfChange
env:
DATABASE_URL: postgres://prod:5432/myapp
REDIS_URL: redis://prod:6379
# LINT.ThenChange(src/config.py)
If env changes, src/config.py must also be modified somewhere in the diff.
With labels, narrow the requirement to a specific section:
# deploy/app.yml | # src/config.py
# LINT.IfChange("env") | # LINT.Label("env")
env: | DATABASE_URL = os.environ["DATABASE_URL"]
DATABASE_URL: postgres://prod:5432/myapp | REDIS_URL = os.environ["REDIS_URL"]
REDIS_URL: redis://prod:6379 | # LINT.EndLabel
# LINT.ThenChange(src/config.py#env) |
Multiple targets:
# deploy/app.yml
# LINT.IfChange("env")
env:
DATABASE_URL: postgres://prod:5432/myapp
# LINT.ThenChange([
# "src/config.py#env",
# "docs/env-reference.md",
# ])
Absolute paths (repo-root-relative)
A leading / resolves from the repo root, not the filesystem root. This works regardless of where you run ifchange within the repo. The repo root is detected by walking up from CWD looking for .git, .hg, .jj, .svn, .pijul, .fslckout, or _FOSSIL_:
# deploy/app.yml (anywhere in the repo)
# LINT.IfChange
env:
DATABASE_URL: postgres://prod:5432/myapp
# LINT.ThenChange(/src/config.py#env)
/src/config.py resolves to <repo-root>/src/config.py. Without the leading /, paths are relative to the source file's directory.
Directory targets
A trailing / marks a directory target (like .gitignore conventions). ThenChange(lib/) means "if this block changes, at least one file anywhere under lib/ must also change in the diff." Matching is recursive.
# src/api.py
# LINT.IfChange
SCHEMA_VERSION = 3
# LINT.ThenChange(generated/)
If SCHEMA_VERSION changes, at least one file under generated/ (e.g. generated/models.py, generated/sub/types.py) must also appear in the diff.
Rules:
ThenChange(lib/)(trailing slash) = directory target, recursive matchingThenChange(lib)wherelibis a directory = error with suggestion to add/- Labels are not supported for directory targets (
ThenChange(lib/#label)is an error) - Directory must exist on disk during scan validation
- If the directory was deleted (not on disk), lint reports an error (same as deleted file targets)
Directory targets can be mixed with file targets:
# LINT.ThenChange(generated/, docs/schema.md)
Self-references
Point to a label in the same file with #label (no filename):
# deploy/app.yml
env:
DATABASE_URL: postgres://prod:5432/myapp
# LINT.IfChange
REDIS_URL: redis://prod:6379
# LINT.ThenChange(#redis)
# ...
# LINT.Label("redis")
redis:
host: prod
port: 6379
# LINT.EndLabel
Cross-references
When two or more files reference each other, only changes within an IfChange section trigger validation, not changes elsewhere in the file.
Best practice
Source of truth points at derived files. Bidirectional fencing only when both sides are live code.
CI / Automation
Run it as a pre-commit hook, or as a GitHub Action. See examples/.
GitHub Action
- uses: slnc/ifchange@v1
| Input | Description | Default |
|---|---|---|
version |
Release tag to install (e.g. v1.0.0). Empty means latest. |
latest |
args |
Extra arguments passed to ifchange | |
diff |
Path to a pre-built diff file. If empty, the action generates one. | |
token |
GitHub token for downloading release assets | github.token |
Pre-commit hook
repos:
- repo: https://github.com/slnc/ifchange
rev: v0.1.0
hooks:
- id: ifchange # requires ifchange in PATH
- id: ifchange-pypi # OR: auto-downloads binary via PyPI
Performance
Wall-clock time to lint a 30k-line diff or scan all directives in a synthetic 5000-file repo (21 language types, 12-core x86_64) vs the original TypeScript implementation.
| Mode | Rust | TypeScript | Speedup |
|---|---|---|---|
| Lint | ~17 ms | 714 ms | ~42x |
| Scan | ~34 ms | 387 ms | ~12x |
Versioning
- We follow semver.
- Stability guarantees.
- During
0.x, minor versions may include breaking changes.
Supported Languages
.ada |
.cr |
.gleam |
.kt |
.proto |
.swift |
.adb |
.cs |
.go |
.kts |
.ps1 |
.tex |
.ads |
.css |
.gradle |
.latex |
.psd1 |
.tf |
.asm |
.cxx |
.groovy |
.less |
.psm1 |
.tfvars |
.bas |
.dart |
.h |
.lisp |
.py |
.thrift |
.bash |
.el |
.hcl |
.lsp |
.r |
.toml |
.bat |
.env |
.hh |
.lua |
.rb |
.ts |
.bzl |
.erb |
.hpp |
.m |
.rkt |
.tsx |
.c |
.erl |
.hrl |
.md |
.rs |
.v |
.c++ |
.ex |
.hs |
.mjs |
.s |
.vb |
.cc |
.exs |
.htm |
.mk |
.sass |
.vba |
.cjs |
.f |
.html |
.mm |
.scala |
.vhd |
.cl |
.f03 |
.hxx |
.mojo |
.scm |
.vhdl |
.clj |
.f08 |
.ini |
.mts |
.scss |
.vue |
.cljc |
.f90 |
.java |
.nim |
.sh |
.xml |
.cljs |
.f95 |
.jl |
.nix |
.sql |
.xsl |
.cls |
.for |
.js |
.php |
.sty |
.xslt |
.cmake |
.fs |
.jsonc |
.pl |
.styl |
.yaml |
.cmd |
.fsi |
.jsp |
.pm |
.sv |
.yml |
.conf |
.fsx |
.jsx |
.pro |
.svelte |
.zig |
.cpp |
.gd |
.ksh |
.prolog |
.svg |
.zsh |
Special files: Dockerfile{,.*}, .gitignore, go.mod
Recommended AGENTS.md / CLAUDE.md
Copy this snippet into your repository's AGENTS.md so coding agents use ifchange directives correctly.
- When two code sections need to change in sync, use ifchange comment directives to enforce it.
### Example
```yaml
# deploy/app.yml
# LINT.IfChange
env:
DATABASE_URL: postgres://prod:5432/myapp
REDIS_URL: redis://prod:6379
# LINT.ThenChange(src/config.py#env)
# src/config.py
# LINT.Label(env)
DATABASE_URL = os.environ["DATABASE_URL"]
REDIS_URL = os.environ["REDIS_URL"]
# LINT.EndLabel
Run ifchange --help for full syntax and options.
</details>
## [Architecture](docs/ARCHITECTURE.md) · [Contributing](docs/CONTRIBUTING.md) · [Versioning](docs/VERSIONING.md) · [License (MIT)](LICENSE)
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ifchange-0.3.0.tar.gz.
File metadata
- Download URL: ifchange-0.3.0.tar.gz
- Upload date:
- Size: 6.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ac9373c7d26a131c98f731d6ad619d1f79112b2f0de0cd7b1f70a66a7acf8151
|
|
| MD5 |
9737b59a2d000bdb7ac74f8e4100c427
|
|
| BLAKE2b-256 |
c06e22d457b4e86284ff6e7b80c312f9a9060260c097e0fd09919c3cda80a6e3
|
Provenance
The following attestation bundles were made for ifchange-0.3.0.tar.gz:
Publisher:
release-binaries.yml on slnc/ifchange
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ifchange-0.3.0.tar.gz -
Subject digest:
ac9373c7d26a131c98f731d6ad619d1f79112b2f0de0cd7b1f70a66a7acf8151 - Sigstore transparency entry: 1154426095
- Sigstore integration time:
-
Permalink:
slnc/ifchange@9bc38783b8d50647084110d8b6eb09dbba1a0de2 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/slnc
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-binaries.yml@9bc38783b8d50647084110d8b6eb09dbba1a0de2 -
Trigger Event:
repository_dispatch
-
Statement type:
File details
Details for the file ifchange-0.3.0-py3-none-any.whl.
File metadata
- Download URL: ifchange-0.3.0-py3-none-any.whl
- Upload date:
- Size: 7.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4bfc1dfde1e5aace59e643cfcc5ab6e7d5cbe253e40011dc73b38ed06ad69bad
|
|
| MD5 |
c08d4894c9e8318f80aa9428f28fe54d
|
|
| BLAKE2b-256 |
a98fd26e4b5e96e32d94fd7aa6005c04d6b42e6633d3241046457317ba66721a
|
Provenance
The following attestation bundles were made for ifchange-0.3.0-py3-none-any.whl:
Publisher:
release-binaries.yml on slnc/ifchange
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ifchange-0.3.0-py3-none-any.whl -
Subject digest:
4bfc1dfde1e5aace59e643cfcc5ab6e7d5cbe253e40011dc73b38ed06ad69bad - Sigstore transparency entry: 1154426097
- Sigstore integration time:
-
Permalink:
slnc/ifchange@9bc38783b8d50647084110d8b6eb09dbba1a0de2 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/slnc
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-binaries.yml@9bc38783b8d50647084110d8b6eb09dbba1a0de2 -
Trigger Event:
repository_dispatch
-
Statement type: