Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,17 @@ Use -header-filter=.* to display errors from all non-system headers. Use -system
files: ^(src|include)/.*\.(cpp|cc|cxx|h|hpp)$
```

For `clang-tidy`, you can also process multiple files in parallel by adding `--jobs`
or `-j`:

```yaml
- repo: https://github.com/cpp-linter/cpp-linter-hooks
rev: v1.2.0
hooks:
- id: clang-tidy
args: [--checks=.clang-tidy, --version=21, --jobs=4]
```

Comment thread
shenxianpeng marked this conversation as resolved.
Alternatively, if you want to run the hooks manually on only the changed files, you can use the following command:

```bash
Expand Down
71 changes: 69 additions & 2 deletions cpp_linter_hooks/clang_tidy.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,48 @@
from concurrent.futures import ThreadPoolExecutor
import subprocess
import sys
from argparse import ArgumentParser
from argparse import ArgumentParser, ArgumentTypeError
from pathlib import Path
from typing import Optional, Tuple
from typing import List, Optional, Tuple

from cpp_linter_hooks.util import resolve_install, DEFAULT_CLANG_TIDY_VERSION

COMPILE_DB_SEARCH_DIRS = ["build", "out", "cmake-build-debug", "_build"]
SOURCE_FILE_SUFFIXES = {
".c",
".cc",
".cp",
".cpp",
".cxx",
".c++",
".cu",
".cuh",
".h",
".hh",
".hpp",
".hxx",
".h++",
".ipp",
".inl",
".ixx",
".tpp",
".txx",
}


def _positive_int(value: str) -> int:
jobs = int(value)
if jobs < 1:
raise ArgumentTypeError("--jobs must be greater than 0")
return jobs

parser = ArgumentParser()
parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION)
parser.add_argument("--compile-commands", default=None, dest="compile_commands")
parser.add_argument(
"--no-compile-commands", action="store_true", dest="no_compile_commands"
)
parser.add_argument("-j", "--jobs", type=_positive_int, default=1)
parser.add_argument("-v", "--verbose", action="store_true")


Expand Down Expand Up @@ -74,6 +103,38 @@ def _exec_clang_tidy(command) -> Tuple[int, str]:
return 1, str(e)


def _looks_like_source_file(path: str) -> bool:
return any(suffix.lower() in SOURCE_FILE_SUFFIXES for suffix in Path(path).suffixes)
Comment thread
shenxianpeng marked this conversation as resolved.
Outdated


def _split_source_files(args: List[str]) -> Tuple[List[str], List[str]]:
split_idx = len(args)
source_files: List[str] = []
for idx in range(len(args) - 1, -1, -1):
if not _looks_like_source_file(args[idx]):
break
source_files.append(args[idx])
split_idx = idx
return args[:split_idx], list(reversed(source_files))


def _combine_outputs(results: List[Tuple[int, str]]) -> str:
return "\n".join(output.rstrip("\n") for _, output in results if output)


def _exec_parallel_clang_tidy(
command_prefix: List[str], source_files: List[str], jobs: int
) -> Tuple[int, str]:
def run_file(source_file: str) -> Tuple[int, str]:
return _exec_clang_tidy(command_prefix + [source_file])

with ThreadPoolExecutor(max_workers=min(jobs, len(source_files))) as executor:
results = list(executor.map(run_file, source_files))

retval = 1 if any(retval != 0 for retval, _ in results) else 0
return retval, _combine_outputs(results)


def run_clang_tidy(args=None) -> Tuple[int, str]:
hook_args, other_args = parser.parse_known_args(args)
if hook_args.version:
Expand All @@ -90,6 +151,12 @@ def run_clang_tidy(args=None) -> Tuple[int, str]:
)
other_args = ["-p", compile_db_path] + other_args

clang_tidy_args, source_files = _split_source_files(other_args)
if hook_args.jobs > 1 and len(source_files) > 1:
Comment thread
shenxianpeng marked this conversation as resolved.
Outdated
return _exec_parallel_clang_tidy(
["clang-tidy"] + clang_tidy_args, source_files, hook_args.jobs
)
Comment on lines +157 to +168
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

clang-tidy command line reference: do --fix, --fix-errors, and --fix-notes modify source/header files, and are concurrent invocations safe when multiple translation units include the same header?

💡 Result:

Yes, --fix, --fix-errors, and --fix-notes modify the source and header files in place. No, concurrent invocations are not safe when multiple translation units include the same header, as parallel processes can generate conflicting fixes for the shared header, leading to race conditions during file writes.

Citations:


Treat the --fix* modes as unsafe here too.

This branch only falls back for --export-fixes. At minimum, --fix, --fix-errors, and --fix-notes also need the same serial fallback because they can rewrite files shared across translation units and make the hook nondeterministic.

🛡️ Suggested guard expansion
-    unsafe_parallel = any(
-        arg == "--export-fixes" or arg.startswith("--export-fixes=")
-        for arg in clang_tidy_args
-    )
+    unsafe_parallel_flags = {
+        "--export-fixes",
+        "--fix",
+        "--fix-errors",
+        "--fix-notes",
+    }
+    unsafe_parallel = any(
+        arg in unsafe_parallel_flags or arg.startswith("--export-fixes=")
+        for arg in clang_tidy_args
+    )
🧰 Tools
🪛 Ruff (0.15.7)

[warning] 167-167: Consider ["clang-tidy", *clang_tidy_args] instead of concatenation

Replace with ["clang-tidy", *clang_tidy_args]

(RUF005)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cpp_linter_hooks/clang_tidy.py` around lines 157 - 168, The parallel-safety
check only blocks "--export-fixes" but must also treat clang-tidy's fixing modes
as unsafe; update the unsafe_parallel predicate (which examines clang_tidy_args)
to also return true when any arg enables fixes (e.g., arg == "--fix" or
arg.startswith("--fix") / arg.startswith("--fix-") or arg.startswith("--fix=")
and similarly cover "--fix-errors" and "--fix-notes"), so that when
hook_args.jobs > 1 and multiple source_files the code will fall back to the
serial path via _exec_parallel_clang_tidy instead of running concurrently when
fix flags are present.


return _exec_clang_tidy(["clang-tidy"] + other_args)


Expand Down
69 changes: 69 additions & 0 deletions tests/test_clang_tidy.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pytest
import subprocess
import time
from pathlib import Path
from unittest.mock import patch, MagicMock

Expand Down Expand Up @@ -227,3 +228,71 @@ def test_no_verbose_no_extra_stderr(tmp_path, monkeypatch, capsys):
):
run_clang_tidy(["dummy.cpp"])
assert capsys.readouterr().err == ""


def test_jobs_one_keeps_single_invocation():
with (
patch(
"cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "")
) as mock_exec,
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
):
run_clang_tidy(["--jobs=1", "-p", "./build", "a.cpp", "b.cpp"])

mock_exec.assert_called_once_with(
["clang-tidy", "-p", "./build", "a.cpp", "b.cpp"]
)


def test_jobs_parallelizes_source_files_and_preserves_output_order():
def fake_exec(command):
source_file = command[-1]
if source_file == "a.cpp":
time.sleep(0.05)
return 0, "a.cpp output"
return 1, "b.cpp output"

Comment on lines +245 to +252
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test relies on time.sleep() to try to force out-of-order completion, but thread scheduling can still allow a.cpp to complete before b.cpp, making the ordering assertion non-deterministic (and potentially missing regressions if output were combined by completion order). Using synchronization (e.g., threading.Event/Barrier) to guarantee b.cpp finishes first would make the test reliable.

Copilot uses AI. Check for mistakes.
with (
patch("cpp_linter_hooks.clang_tidy._exec_clang_tidy", side_effect=fake_exec),
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
):
ret, output = run_clang_tidy(
[
"--jobs=4",
"-p",
"./build",
"--export-fixes",
"fixes.yaml",
"a.cpp",
"b.cpp",
]
)

assert ret == 1
assert output == "a.cpp output\nb.cpp output"


def test_jobs_parallelizes_only_trailing_source_files():
with (
patch(
"cpp_linter_hooks.clang_tidy._exec_clang_tidy", return_value=(0, "")
) as mock_exec,
patch("cpp_linter_hooks.clang_tidy.resolve_install"),
):
run_clang_tidy(
[
"--jobs=2",
"-p",
"./build",
"--export-fixes",
"fixes.yaml",
"a.cpp",
"b.hpp",
]
)

commands = {tuple(call.args[0]) for call in mock_exec.call_args_list}
assert commands == {
("clang-tidy", "-p", "./build", "--export-fixes", "fixes.yaml", "a.cpp"),
("clang-tidy", "-p", "./build", "--export-fixes", "fixes.yaml", "b.hpp"),
}
Loading