Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ on:
branches: [ "main" ]
workflow_dispatch:

permissions: {}

jobs:
codeql:
permissions:
actions: read
contents: read
security-events: write
uses: cpp-linter/.github/.github/workflows/codeql.yml@main
with:
language: python
12 changes: 9 additions & 3 deletions .github/workflows/codspeed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,34 @@ on:
branches:
- "main"
paths:
- "cpp-linter-hooks/**"
- "cpp_linter_hooks/**"
- "tests/**"
- ".github/workflows/codspeed.yml"
- "pyproject.toml"
pull_request:
branches:
- "main"
paths:
- "cpp-linter-hooks/**"
- "cpp_linter_hooks/**"
- "tests/**"
- ".github/workflows/codspeed.yml"
- "pyproject.toml"
# `workflow_dispatch` allows CodSpeed to trigger backtest
# performance analysis in order to generate initial data.
workflow_dispatch:

permissions: {}

jobs:
benchmarks:
name: Run benchmarks
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
Expand All @@ -38,7 +44,7 @@ jobs:
run: uv sync --dev

- name: Run benchmarks
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea #5.2.1
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
with:
token: ${{ secrets.CODSPEED_TOKEN }}
run: uv run pytest tests/ --codspeed
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ on:
branches: main
workflow_dispatch:

permissions: {}

jobs:
pre-commit:
permissions:
contents: read
uses: cpp-linter/.github/.github/workflows/pre-commit.yml@main
7 changes: 6 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ name: Upload Python Package

on:
release:
branches: [main]
types: [published]
workflow_dispatch:

permissions: {}

jobs:
deploy:
permissions:
contents: read
id-token: write # trusted signing of build provenance attestations
attestations: write
uses: cpp-linter/.github/.github/workflows/py-publish.yml@main
secrets: inherit
24 changes: 15 additions & 9 deletions .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,27 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Generate release notes header
run: |
echo "## 💡 Default Clang Tool Versions" > release_notes.md
echo "" >> release_notes.md
echo "Versions are resolved dynamically from PyPI at hook runtime, always using the latest stable release by default. " >> release_notes.md
echo "You can pin a specific version by adding the \`--version\` argument under \`args\` in your pre-commit config." >> release_notes.md
echo "See [Custom Clang Tool Version](https://github.com/cpp-linter/cpp-linter-hooks?tab=readme-ov-file#custom-clang-tool-version) for details." >> release_notes.md
echo "" >> release_notes.md
{
echo "## 💡 Default Clang Tool Versions"
echo ""
echo "Versions are resolved dynamically from PyPI at hook runtime, always using the latest stable release by default. "
echo "You can pin a specific version by adding the \`--version\` argument under \`args\` in your pre-commit config."
echo "See [Custom Clang Tool Version](https://github.com/cpp-linter/cpp-linter-hooks?tab=readme-ov-file#custom-clang-tool-version) for details."
echo ""
} > release_notes.md
cat release_notes.md

# Export release notes content to env variable for Release Drafter
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
cat release_notes.md >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
{
echo "RELEASE_BODY<<EOF"
cat release_notes.md
echo "EOF"
} >> "$GITHUB_ENV"

# Draft your next Release notes as Pull Requests are merged into the default branch
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ on:
branches: [main]
workflow_dispatch:

permissions: {}

jobs:
test:
permissions:
contents: read
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
Expand All @@ -18,6 +22,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
Expand All @@ -40,6 +46,7 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
plugins: noop # coverage.xml is generated above
fail_ci_if_error: true # optional (default = false)
verbose: true # optional (default = false)
- name: Test cpp-linter-hooks
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ repos:
- id: check-toml
- id: requirements-txt-fixer
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.3
rev: v0.16.10
hooks:
# Run the linter.
- id: ruff-check
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ A pre-commit hook repo that auto-installs and runs `clang-format` and `clang-tid

| File | Purpose |
|------|---------|
| `pyproject.toml` | Build config, deps (`pip>=26.1`, `tomli`), dev deps, entry points |
| `pyproject.toml` | Build config, deps (`pip>=26.1`), dev deps, entry points |
| `cpp_linter_hooks/util.py` | 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`, error hints) |
Expand Down
7 changes: 4 additions & 3 deletions cpp_linter_hooks/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,10 @@ def _resolve_version_from_pypi(
if user_input in versions:
return user_input, None

# Prefix match (e.g. "20" → "20.1.8"). Versions are newest-first,
# so the first matching entry is the latest for that prefix.
matched = [v for v in versions if v.startswith(user_input)]
# Prefix match on whole version components (e.g. "20" → "20.1.8", while
# "2" must not match "20.1.8"). Versions are newest-first, so the first
# matching entry is the latest for that prefix.
matched = [v for v in versions if v.startswith(f"{user_input}.")]
if matched:
return matched[0], None

Expand Down
7 changes: 1 addition & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ classifiers = [
]
dependencies = [
"pip>=26.1", # Required to install clang-format and clang-tidy
"tomli>=1.1.0; python_version < '3.11'",
]
dynamic = ["version"]

Expand All @@ -56,9 +55,6 @@ zip-safe = false
packages = ["cpp_linter_hooks"]
include-package-data = true

[tool.setuptools.package-data]
cpp_linter_hooks = ["../pyproject.toml"]

[tool.setuptools_scm]
# It would be nice to include the commit hash in the version, but that
# can't be done in a PEP 440-compatible way.
Expand All @@ -76,6 +72,5 @@ omit = ["tests/*"]

[tool.coverage.report]
exclude_also = [
"__name__",
"FileNotFoundError"
'if __name__ == .__main__.:',
]
150 changes: 150 additions & 0 deletions tests/test_clang_format.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch

Expand Down Expand Up @@ -167,3 +169,151 @@ def test_main_prints_failure_output(monkeypatch, capsys):

assert main() == 1
assert capsys.readouterr().out == "formatting failed\n"


# --- offline tests: tool resolution and the clang-format process are mocked ---


@pytest.fixture
def mock_clang_format():
"""Mock tool resolution and the clang-format process."""
with (
patch(
"cpp_linter_hooks.clang_format.resolve_install_with_diagnostics",
return_value=(None, None),
) as mock_resolve,
patch("cpp_linter_hooks.clang_format.subprocess.run") as mock_run,
):
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=""
)
yield mock_resolve, mock_run


def test_run_clang_format_formats_files_in_place(mock_clang_format):
mock_resolve, mock_run = mock_clang_format

assert run_clang_format(["--style=Google", "a.cpp", "b.cpp"]) == (0, "")

mock_resolve.assert_called_once_with("clang-format", None, False)
mock_run.assert_called_once_with(
["clang-format", "-i", "--style=Google", "a.cpp", "b.cpp"],
capture_output=True,
encoding="utf-8",
check=False,
)


@pytest.mark.parametrize(
("args", "expected_command"),
(
(
["--dry-run", "a.cpp"],
["clang-format", "-i", "--dry-run", "a.cpp", "--Werror"],
),
(
["--dry-run", "--Werror", "a.cpp"],
["clang-format", "-i", "--dry-run", "--Werror", "a.cpp"],
),
(["--Werror", "a.cpp"], ["clang-format", "-i", "--Werror", "a.cpp"]),
(["a.cpp"], ["clang-format", "-i", "a.cpp"]),
),
)
def test_run_clang_format_adds_werror_only_for_dry_run(
mock_clang_format, args, expected_command
):
_, mock_run = mock_clang_format

run_clang_format(args)

assert mock_run.call_args.args[0] == expected_command


def test_run_clang_format_combines_stdout_and_stderr(mock_clang_format):
_, mock_run = mock_clang_format
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=1, stdout="a.cpp needs formatting\n", stderr="error: x\n"
)

assert run_clang_format(["--dry-run", "a.cpp"]) == (
1,
"a.cpp needs formatting\nerror: x\n",
)


def test_run_clang_format_handles_missing_streams(mock_clang_format):
_, mock_run = mock_clang_format
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout=None, stderr=None
)

assert run_clang_format(["a.cpp"]) == (0, "")


def test_run_clang_format_reports_missing_executable(mock_clang_format):
_, mock_run = mock_clang_format
mock_run.side_effect = FileNotFoundError(
2, "No such file or directory", "clang-format"
)

assert run_clang_format(["a.cpp"]) == (
1,
"[Errno 2] No such file or directory: 'clang-format'",
)


def test_run_clang_format_verbose_prints_command_details(mock_clang_format, capsys):
_, mock_run = mock_clang_format
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="Invalid value for -style\n"
)

ret, output = run_clang_format(["-v", "--style=Bogus", "a.cpp"])

assert (ret, output) == (1, "Invalid value for -style\n")
assert mock_run.call_args.args[0] == [
"clang-format",
"-i",
"--verbose",
"--style=Bogus",
"a.cpp",
]
stderr = capsys.readouterr().err
assert "Command executed: clang-format -i --verbose --style=Bogus a.cpp" in stderr
assert "Exit code: 1" in stderr
assert "Output: Invalid value for -style" in stderr


def test_run_clang_format_verbose_omits_empty_output(mock_clang_format, capsys):
run_clang_format(["--verbose", "a.cpp"])

stderr = capsys.readouterr().err
assert "Exit code: 0" in stderr
assert "Output:" not in stderr


def test_main_reads_arguments_from_command_line(mock_clang_format, monkeypatch, capsys):
mock_resolve, mock_run = mock_clang_format
monkeypatch.setattr(
sys, "argv", ["clang-format-hook", "--version=21", "--style=file", "a.cpp"]
)

assert main() == 0

mock_resolve.assert_called_once_with("clang-format", "21", False)
assert mock_run.call_args.args[0] == [
"clang-format",
"-i",
"--style=file",
"a.cpp",
]
assert capsys.readouterr().out == ""


def test_main_does_not_print_blank_failure_output(monkeypatch, capsys):
monkeypatch.setattr(
"cpp_linter_hooks.clang_format.run_clang_format", lambda: (1, " \n")
)

assert main() == 1
assert capsys.readouterr().out == ""
Loading
Loading