Skip to content
Merged
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ A pre-commit hook repo that auto-installs and runs `clang-format` and `clang-tid
| `pyproject.toml` | Build config, deps (`pip>=26.1`), dev deps, entry points |
| `cpp_linter_hooks/util.py` | Installed-wheel check, PyPI version resolution + pip install (prefix matching, offline fallback) |
| `cpp_linter_hooks/clang_format.py` | clang-format wrapper (`--verbose`, `--dry-run`) |
| `cpp_linter_hooks/clang_tidy.py` | clang-tidy wrapper (`--compile-commands`, `--jobs`, `--fix`, `-quiet` + status-line filter, `--checks` repair, error hints) |
| `cpp_linter_hooks/clang_tidy.py` | clang-tidy wrapper (`--compile-commands`, missing-database policy, `--jobs`, `--fix`, `--all-lines`, `-quiet` + status-line filter, `--checks` repair, error hints) |
| `cpp_linter_hooks/changed_lines.py` | Added line ranges per file from `git diff` (staged changes, or `PRE_COMMIT_FROM_REF...PRE_COMMIT_TO_REF`) |
| `.pre-commit-hooks.yaml` | Hook metadata for pre-commit framework |
| `testing/` | Test fixtures (`main.c`, `good.c`, `.clang-format`, `.clang-tidy`, CMakeLists) |
| `examples/` | Example configs (CMake, Meson, large-project scoping) |
Expand All @@ -44,7 +45,8 @@ bash testing/run.sh # Integration tests (full pre-commit pipeline)

- **Version resolution**: A wheel already installed in the hook environment (found by its `<name>-<version>.dist-info` directory in site-packages, e.g. from `additional_dependencies`, with its executable in the environment's scripts directory) is used without network access when it matches `--version` (exact or prefix) or when no `--version` is given. Otherwise the version is resolved dynamically from PyPI at runtime. No hardcoded version lists. Prefix match supported (`--version=20` → `20.1.8`).
- **Return values**: All hooks return `tuple[int, str]` — `(retval, output)`, where `retval` is the tool's exit code (`0` on success, non-zero on failure; `--dry-run` auto-adds `--Werror` so formatting issues fail).
- **clang-tidy compile DB**: Auto-detected from `build/`, `out/`, `cmake-build-debug/`, `_build/`. Override with `--compile-commands=<dir>`, disable with `--no-compile-commands`.
- **clang-tidy compile DB**: Auto-detected from `build/`, `out/`, `cmake-build-debug/`, `_build/`, `builddir/`, then `cmake-build-*/`, `build-*/`, `build/*/`, `out/build/*/` (newest wins). Override with `--compile-commands=<dir>`, disable with `--no-compile-commands`. If none is found, no `-p`/`--extra-arg*` is given and clang-tidy finds no `compile_commands.json`/`compile_flags.txt` next to the files or in their parents, the hook skips clang-tidy (retval 0, message on stderr), or fails when `CI` is set.
- **clang-tidy changed lines**: By default only diagnostics on added lines are reported, via clang-tidy's `--line-filter`, so `-fix` applies only the fixes of those diagnostics (a fix may still edit an unchanged line nearby). Files without added lines are not checked. If any passed file has no changes (`--all-files`), differs on disk from the diffed version (index or `PRE_COMMIT_TO_REF`), or git fails, every line is checked. `--all-lines` or a user `--line-filter` turns this off. Tests: `tests/conftest.py` disables this and the missing-database check by default; use the `real_changed_lines` / `real_compile_db_check` fixtures to test them.
- **clang-tidy parallel**: `--jobs=N` runs per-file in parallel. Forced serial when `--export-fixes`, `-fix`, or `-fix-errors` is used.
- **clang-tidy output**: Without `--verbose`, the hook adds `-quiet` (unless the user set it) and strips status lines (`N warnings generated.`, `Suppressed N warnings ...`, `Use -header-filter=...`, `[1/N] Processing file ...`). With `--verbose`, output is unchanged.
- **clang-tidy check lists**: `--checks=` / `--warnings-as-errors=` lists that YAML split at commas are joined again, quotes inside the value are removed, and `--checks=<file>` (e.g. `.clang-tidy`) gets a note. Notes are appended to the output, which pre-commit shows when the hook fails.
Expand Down
68 changes: 59 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ repos:

Run `pre-commit install` once in each clone. `--style=file` loads the style from your
`.clang-format` file, and clang-tidy reads your `.clang-tidy` file by itself. The clang-tidy hook
needs a `compile_commands.json`, which it looks for in `build/` and a few other directories (see
[Compilation database](https://github.com/cpp-linter/cpp-linter-hooks#compilation-database));
leave it out if you only run clang-tidy in CI, for example with
[cpp-linter-action](https://cpp-linter.github.io/cpp-linter-action/).
reports only problems on the lines that your commit adds or changes (see
[Only changed lines](https://github.com/cpp-linter/cpp-linter-hooks#only-changed-lines)). It needs
a `compile_commands.json`, which it looks for in `build/` and a few other directories; without one
it skips clang-tidy on your machine and fails in CI (see
[Compilation database](https://github.com/cpp-linter/cpp-linter-hooks#compilation-database)).

## Usage

Expand Down Expand Up @@ -163,6 +164,34 @@ The hook passes `-quiet` to clang-tidy and drops status lines such as `522 warni
which count warnings in system headers that clang-tidy does not show. Add `--verbose` to `args` to
keep clang-tidy's full output.

#### Only changed lines

By default the hook reports only the problems on lines that the commit adds or changes, so old
warnings elsewhere in a file do not block your commit. It reads the changes with `git diff`:

- On `git commit` and `pre-commit run`: the staged changes.
- With `pre-commit run --from-ref A --to-ref B` and in the `pre-push` stage: the changes between
the two commits.

The hook passes these lines to clang-tidy's `--line-filter`, so `--fix` applies only the fixes
for problems reported on those lines (a fix can still touch a nearby line, for example to add a
closing brace). Compiler errors are always reported. Files with no added lines, such as a pure rename, are
not checked.

The hook checks every line when the files it gets have no changes, for example with
`pre-commit run --all-files` on a clean working tree, when a file on disk differs from the
version the lines come from (unstaged changes in a direct run, or uncommitted changes on
`pre-push`), or outside a git repository. To always check
every line, for example in a manual stage, add `--all-lines`:

```yaml
- id: clang-tidy
args: [--all-lines]
```

Checking only changed lines does not make clang-tidy faster: it still analyzes the whole file.
See [Performance optimization](https://github.com/cpp-linter/cpp-linter-hooks#performance-optimization).

> [!NOTE]
> Add `--fix` to `args` to automatically apply clang-tidy fixes in place (equivalent to
> passing `-fix` to clang-tidy directly). This is **opt-in** and **not the default** because
Expand All @@ -187,9 +216,13 @@ For CMake or Meson projects, clang-tidy works best with a `compile_commands.json
file that records the exact compiler flags used for each file. Without it, clang-tidy
may report false positives from missing include paths or wrong compiler flags.

The hook auto-detects `compile_commands.json` in common build directories (`build/`,
`out/`, `cmake-build-debug/`, `_build/`) and passes `-p <dir>` to clang-tidy
automatically — no configuration needed for most projects:
The hook auto-detects `compile_commands.json` in common build directories and passes `-p <dir>`
to clang-tidy automatically — no configuration needed for most projects. It looks in `build/`,
`out/`, `cmake-build-debug/`, `_build/` and `builddir/` first, then in `cmake-build-*/`,
`build-*/`, `build/*/` and `out/build/*/` (CMake presets, Visual Studio), where the most recently
written one wins. Without `-p`, clang-tidy also finds a `compile_commands.json` or
`compile_flags.txt` next to each file or in its parent directories, so a database in the
repository root works too.

```yaml
repos:
Expand All @@ -207,7 +240,19 @@ To specify the build directory explicitly:
args: [--compile-commands=build]
```

To disable auto-detection (e.g. in a monorepo where auto-detect might pick the wrong database):
When no compilation database is found, clang-tidy would report missing headers instead of
problems in your code, so the hook:

- **skips clang-tidy on your machine** and passes. The message says how to generate the database;
pre-commit shows it with `pre-commit run -v` or `verbose: true` on the hook.
- **fails in CI**, when the `CI` environment variable is set (GitHub Actions, GitLab CI and most CI
services set it), so a broken setup does not pass unnoticed.

The hook runs clang-tidy anyway when `args` tell it how to compile: `-p`, `--extra-arg` or
`--extra-arg-before`.

To disable auto-detection and always run clang-tidy without a database (e.g. in a monorepo where
auto-detect might pick the wrong database, or for code that needs no compiler flags):

```yaml
- id: clang-tidy
Expand Down Expand Up @@ -264,6 +309,11 @@ or `-j`:
> overwrites a shared `--export-fixes` file and fixes to the same header can collide. Add
> `require_serial: true` to the hook to run it once for all files.

With LLVM 21 and older, clang-tidy enables the `clang-analyzer-*` checks by default unless your
`Checks` start with `-*`, and they take most of its time: in our measurements on
[fmt](https://github.com/fmtlib/fmt), more than 90%. LLVM 22 removed them from the defaults. If
clang-tidy is slow and you do not need them, add `-clang-analyzer-*` to `Checks` in `.clang-tidy`.

Alternatively, if you want to run the hooks manually on only the changed files, you can use the following command:

```bash
Expand All @@ -278,7 +328,7 @@ This approach ensures that only modified files are checked, further speeding up
> Use `-v` or `--verbose` in `args` to enable verbose output.
> For `clang-format`, it shows the list of processed files.
> For `clang-tidy`, it prints which `compile_commands.json` is being used (when auto-detected or explicitly set)
> and keeps clang-tidy's full output, without `-quiet`.
> and which lines are reported, and keeps clang-tidy's full output, without `-quiet`.
> pre-commit shows this output only when the hook fails; add `verbose: true` to the hook to see it on every run.

```yaml
Expand Down
144 changes: 144 additions & 0 deletions cpp_linter_hooks/changed_lines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Find the lines that a commit or push changes, using git."""

import codecs
import os
import re
import subprocess
from collections.abc import Iterator
from pathlib import PurePath

# "@@ -12,3 +14,2 @@": the old side spans 3 lines; the new side starts at line
# 14 and spans 2 lines. A missing count means 1.
HUNK_HEADER = re.compile(r"^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")

LineRanges = list[list[int]]


def diff_range() -> list[str]:
"""Return the git diff arguments for the changes being checked.

pre-commit and prek set PRE_COMMIT_FROM_REF and PRE_COMMIT_TO_REF for
``run --from-ref ... --to-ref ...`` and for the pre-push stage. Otherwise
the hook checks a commit, so the changes are the staged ones.
"""
from_ref = os.environ.get("PRE_COMMIT_FROM_REF")
to_ref = os.environ.get("PRE_COMMIT_TO_REF")
if from_ref and to_ref:
return [f"{from_ref}...{to_ref}"]
return ["--cached"]


def _git_diff(args: list[str]) -> str | None:
"""Run ``git diff`` and return its output, or None if git fails."""
command = [
"git",
"-c",
"core.quotePath=false",
"diff",
"--relative",
"--no-color",
"--no-ext-diff",
*args,
]
try:
sp = subprocess.run(command, capture_output=True, check=False, timeout=60)
except (OSError, subprocess.TimeoutExpired):
return None
if sp.returncode != 0:
return None
return sp.stdout.decode("utf-8", errors="surrogateescape")


def normalize(path: str) -> str:
"""Return a path in the form git prints it, relative and with "/"."""
# PurePath also drops "./" segments.
return PurePath(path).as_posix()


def _unquote_path(path: str) -> str:
"""Undo git's C-style quoting of unusual file names."""
if len(path) >= 2 and path[0] == path[-1] == '"':
raw = codecs.escape_decode(path[1:-1].encode("utf-8"))[0]
return raw.decode("utf-8", errors="surrogateescape")
return path


def _count(group: str | None) -> int:
"""Return a hunk header's line count, which is 1 when git leaves it out."""
return 1 if group is None else int(group)


def _skip_hunk_body(lines: Iterator[str], count: int) -> None:
"""Consume the next count lines of a hunk.

They are skipped, not parsed, because an added line such as "++ i;"
looks like a "+++ " file header. "\\ No newline at end of file" is not
one of the hunk's lines.
"""
while count > 0:
line = next(lines, None)
if line is None:
return
if line[:1] in ("+", "-", " "):
count -= 1


def _parse_added_lines(diff: str) -> dict[str, LineRanges]:
"""Return the added line ranges of each file in a ``git diff -U0`` output."""
ranges: dict[str, LineRanges] = {}
current: LineRanges | None = None
lines = iter(diff.splitlines())
for line in lines:
if line.startswith("+++ "):
name = _unquote_path(line[4:].rstrip("\t"))
current = (
None
if name == "/dev/null"
else ranges.setdefault(name.removeprefix("b/"), [])
)
continue
match = HUNK_HEADER.match(line)
if match is None:
continue
start, count = int(match.group(2)), _count(match.group(3))
if count > 0 and current is not None:
current.append([start, start + count - 1])
_skip_hunk_body(lines, _count(match.group(1)) + count)
return ranges


def changed_lines(files: list[str]) -> dict[str, LineRanges] | None:
"""Return the added line ranges of each file, keyed by the given path.

Returns None when the changed lines are unknown and every line should be
checked: outside a git repository, when a file has no changes, which
means the hook runs on files that were not changed (``--all-files`` or
``--files``), or when a file on disk differs from the version the lines
come from, so the line numbers would not match what clang-tidy reads. A
file that is changed but has no added lines, for example a pure rename,
maps to an empty list.
"""
if not files:
return None
revisions = diff_range()
names = _git_diff(["--name-only", "-z", *revisions])
if names is None:
return None
changed = set(names.split("\0"))
normalized = {path: normalize(path) for path in files}
if any(name not in changed for name in normalized.values()):
return None
# pre-commit hides unstaged changes during a commit, so the files on disk
# match the index. A direct run, --files or a push with uncommitted
# changes can leave them different from the diffed version.
diffed_version = (
[] if revisions == ["--cached"] else [os.environ["PRE_COMMIT_TO_REF"]]
)
if _git_diff(["--quiet", *diffed_version, "--", *files]) is None:
return None

diff = _git_diff(["-U0", "--src-prefix=a/", "--dst-prefix=b/", *revisions])
if diff is None:
return None
added = _parse_added_lines(diff)
return {path: added.get(name, []) for path, name in normalized.items()}
Loading
Loading