Skip to content

Commit b7eb6fb

Browse files
authored
[ci][format] Use clang-format for format check#11582
1 parent b245c18 commit b7eb6fb

5 files changed

Lines changed: 281 additions & 311 deletions

File tree

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,5 @@ The following content must not be changed in the submitted PR message. Otherwise
6161
- [ ] 所有变更均有原因及合理的,并且不会影响到其他软件组件代码或BSP All modifications are justified and not affect other components or BSP
6262
- [ ] 对难懂代码均提供对应的注释 I've commented appropriately where code is tricky
6363
- [ ] 代码是高质量的 Code in this PR is of high quality
64-
- [ ] 已经使用[formatting](https://github.com/mysterywolf/formatting) 等源码格式化工具确保格式符合[RT-Thread代码规范](https://github.com/RT-Thread/rt-thread/blob/master/documentation/contribution_guide/coding_style_cn.md) This PR complies with [RT-Thread code specification](https://github.com/RT-Thread/rt-thread/blob/master/documentation/contribution_guide/coding_style_en.md)
64+
- [ ] 已经使用[clang-format](https://clang.llvm.org/docs/ClangFormat.html) 源码格式化工具确保格式符合[RT-Thread代码规范](https://github.com/RT-Thread/rt-thread/blob/master/documentation/7.contribution/coding_style_cn.md) This PR has been formatted with clang-format and complies with [RT-Thread code specification](https://github.com/RT-Thread/rt-thread/blob/master/documentation/7.contribution/coding_style_en.md)
6565
- [ ] 如果是新增bsp, 已经添加ci检查到[.github/ALL_BSP_COMPILE.json](https://github.com/RT-Thread/rt-thread/blob/master/.github/ALL_BSP_COMPILE.json) 详细请参考链接[BSP自查](https://www.rt-thread.org/document/site/#/rt-thread-version/rt-thread-standard/development-guide/bsp-selfcheck/bsp_selfcheck)

.github/workflows/format_check.yml

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,24 @@ jobs:
1919
name: Scan code format and license
2020
if: github.repository_owner == 'RT-Thread'
2121
steps:
22-
- uses: actions/checkout@main
22+
- uses: actions/checkout@v4
23+
with:
24+
fetch-depth: 0
2325
- name: Set up Python
24-
uses: actions/setup-python@main
26+
uses: actions/setup-python@v5
2527
with:
2628
python-version: 3.8
27-
28-
- name: Check Format and License
29+
30+
- name: Install clang-format
31+
shell: bash
32+
run: |
33+
python -m pip install clang-format
34+
clang-format --version
35+
36+
- name: Check clang-format
2937
shell: bash
3038
run: |
31-
pip install click chardet PyYaml
32-
python tools/ci/file_check.py check 'https://github.com/RT-Thread/rt-thread' 'master'
39+
python tools/ci/clang_format_check.py --repo 'https://github.com/RT-Thread/rt-thread' --branch 'master'
3340
3441
# # Post CI status to PR comment
3542
# post-ci-status:
@@ -42,4 +49,4 @@ jobs:
4249
# pr_number: ${{ github.event.pull_request.number }}
4350
# permissions:
4451
# pull-requests: write
45-
# issues: write
52+
# issues: write

bsp/.clang-format

Lines changed: 0 additions & 11 deletions
This file was deleted.

tools/ci/clang_format_check.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
#
2+
# Copyright (c) 2006-2026, RT-Thread Development Team
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
#
6+
# Change Logs:
7+
# Date Author Notes
8+
# 2026-07-07 RT-Thread add clang-format CI check
9+
#
10+
11+
import argparse
12+
import fnmatch
13+
import logging
14+
import re
15+
import subprocess
16+
import sys
17+
from pathlib import Path
18+
19+
20+
SOURCE_EXTENSIONS = (
21+
".c",
22+
".h",
23+
".cpp",
24+
".hpp",
25+
".cc",
26+
".hh",
27+
".C",
28+
".H",
29+
".cp",
30+
".cxx",
31+
".hxx",
32+
".inc",
33+
".inl",
34+
".ipp",
35+
".tpp",
36+
".txx",
37+
)
38+
39+
40+
HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
41+
42+
43+
def init_logger():
44+
logging.basicConfig(level=logging.INFO, format="[%(filename)s %(lineno)d %(levelname)s] %(message)s ")
45+
46+
47+
def run_git(args, check=True):
48+
result = subprocess.run(["git"] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
49+
if check and result.returncode != 0:
50+
raise RuntimeError(result.stderr.decode("utf-8", errors="replace"))
51+
return result
52+
53+
54+
def ensure_remote(remote, repo):
55+
result = run_git(["remote", "get-url", remote], check=False)
56+
if result.returncode == 0:
57+
run_git(["remote", "set-url", remote, repo])
58+
else:
59+
run_git(["remote", "add", remote, repo])
60+
61+
62+
def split_git_paths(output):
63+
paths = []
64+
for item in output.split(b"\0"):
65+
if item:
66+
paths.append(item.decode("utf-8", errors="surrogateescape"))
67+
return paths
68+
69+
70+
def get_changed_files(repo, branch, remote):
71+
ensure_remote(remote, repo)
72+
run_git(["fetch", "--no-tags", remote, branch])
73+
74+
merge_base = run_git(["merge-base", "FETCH_HEAD", "HEAD"]).stdout.decode("utf-8", errors="replace").strip()
75+
if not merge_base:
76+
raise RuntimeError("git merge-base FETCH_HEAD HEAD returned no commit")
77+
78+
diff_range = [merge_base, "HEAD"]
79+
result = run_git(["diff", "--name-only", "-z", "--diff-filter=ACMR", "--no-renames"] + diff_range + ["--"])
80+
return split_git_paths(result.stdout), diff_range
81+
82+
83+
def merge_line_ranges(ranges):
84+
if not ranges:
85+
return []
86+
87+
merged = []
88+
for start, end in sorted(ranges):
89+
if not merged or start > merged[-1][1] + 1:
90+
merged.append([start, end])
91+
else:
92+
merged[-1][1] = max(merged[-1][1], end)
93+
return [(start, end) for start, end in merged]
94+
95+
96+
def parse_changed_line_ranges(diff_text):
97+
ranges = []
98+
for line in diff_text.splitlines():
99+
match = HUNK_HEADER.match(line)
100+
if not match:
101+
continue
102+
103+
start = int(match.group(1))
104+
count = int(match.group(2) or "1")
105+
if count == 0:
106+
continue
107+
ranges.append((start, start + count - 1))
108+
109+
return merge_line_ranges(ranges)
110+
111+
112+
def get_changed_line_ranges(diff_range, files):
113+
line_ranges = {}
114+
for file_path in files:
115+
result = run_git(["diff", "--unified=0", "--diff-filter=ACMR", "--no-renames"] + diff_range + ["--", file_path])
116+
line_ranges[file_path] = parse_changed_line_ranges(result.stdout.decode("utf-8", errors="replace"))
117+
return line_ranges
118+
119+
120+
def normalize_path(path):
121+
return path.as_posix().strip("/")
122+
123+
124+
def match_path(path, pattern):
125+
return fnmatch.fnmatchcase(path, pattern) or fnmatch.fnmatchcase("/" + path, pattern)
126+
127+
128+
def ignore_pattern_matches(pattern, relative_file):
129+
pattern = pattern.strip()
130+
if not pattern or pattern.startswith("#"):
131+
return False
132+
133+
pattern = pattern.lstrip("/")
134+
dir_only = pattern.endswith("/")
135+
pattern = pattern.rstrip("/")
136+
if not pattern:
137+
return False
138+
139+
relative_file = relative_file.strip("/")
140+
if dir_only:
141+
parts = relative_file.split("/")
142+
dirs = ["/".join(parts[:index]) for index in range(1, len(parts))]
143+
return match_path(relative_file, pattern) or any(match_path(directory, pattern) for directory in dirs)
144+
145+
if "/" not in pattern:
146+
return any(fnmatch.fnmatchcase(part, pattern) for part in relative_file.split("/"))
147+
148+
return match_path(relative_file, pattern)
149+
150+
151+
def is_ignored_by_clang_format(root, file_path):
152+
absolute_file = (root / file_path).resolve()
153+
try:
154+
relative_to_root = absolute_file.relative_to(root)
155+
except ValueError:
156+
return False
157+
158+
current_dir = absolute_file.parent
159+
ignore_files = []
160+
while True:
161+
ignore_file = current_dir / ".clang-format-ignore"
162+
if ignore_file.exists():
163+
ignore_files.append(ignore_file)
164+
if current_dir == root:
165+
break
166+
current_dir = current_dir.parent
167+
168+
for ignore_file in reversed(ignore_files):
169+
try:
170+
ignore_dir = ignore_file.parent.relative_to(root)
171+
if str(ignore_dir) == ".":
172+
relative_file = normalize_path(relative_to_root)
173+
else:
174+
relative_file = normalize_path(relative_to_root.relative_to(ignore_dir))
175+
except ValueError:
176+
continue
177+
with open(ignore_file, "r", encoding="utf-8") as ignore:
178+
for line in ignore:
179+
if ignore_pattern_matches(line, relative_file):
180+
logging.info("ignore file by %s: %s", ignore_file.relative_to(root).as_posix(), file_path)
181+
return True
182+
return False
183+
184+
185+
def filter_source_files(root, files):
186+
source_files = []
187+
for file_path in files:
188+
path = root / file_path
189+
if not path.is_file():
190+
continue
191+
if not file_path.endswith(SOURCE_EXTENSIONS):
192+
continue
193+
if is_ignored_by_clang_format(root, Path(file_path)):
194+
continue
195+
source_files.append(file_path)
196+
return source_files
197+
198+
199+
def check_clang_format(files, clang_format, line_ranges=None):
200+
failed_files = []
201+
for file_path in files:
202+
invocation = [clang_format, "--dry-run", "--Werror", "--style=file"]
203+
if line_ranges is not None:
204+
ranges = line_ranges.get(file_path, [])
205+
if not ranges:
206+
logging.info("skip clang-format, no changed lines -> %s", file_path)
207+
continue
208+
for start, end in ranges:
209+
invocation.append("--lines={}:{}".format(start, end))
210+
211+
invocation.append(file_path)
212+
logging.info("check clang-format -> %s", file_path)
213+
result = subprocess.run(
214+
invocation,
215+
stdout=subprocess.PIPE,
216+
stderr=subprocess.PIPE,
217+
universal_newlines=True,
218+
)
219+
if result.stdout:
220+
sys.stdout.write(result.stdout)
221+
if result.stderr:
222+
sys.stderr.write(result.stderr)
223+
if result.returncode != 0:
224+
failed_files.append(file_path)
225+
226+
if failed_files:
227+
logging.error("clang-format check failed, please run clang-format on the files above.")
228+
return False
229+
return True
230+
231+
232+
def main():
233+
parser = argparse.ArgumentParser(description="Check changed source files with clang-format.")
234+
parser.add_argument("--repo", default="https://github.com/RT-Thread/rt-thread")
235+
parser.add_argument("--branch", default="master")
236+
parser.add_argument("--remote", default="rtt_repo")
237+
parser.add_argument("--clang-format-executable", default="clang-format")
238+
parser.add_argument("files", nargs="*", help="files to check; git diff is used when omitted")
239+
args = parser.parse_args()
240+
241+
init_logger()
242+
root = Path.cwd().resolve()
243+
if args.files:
244+
files = args.files
245+
line_ranges = None
246+
else:
247+
files, diff_range = get_changed_files(args.repo, args.branch, args.remote)
248+
line_ranges = get_changed_line_ranges(diff_range, files)
249+
250+
source_files = filter_source_files(root, files)
251+
if line_ranges is not None:
252+
line_ranges = {file_path: line_ranges.get(file_path, []) for file_path in source_files}
253+
254+
if not source_files:
255+
logging.warning("There are no source files to check format.")
256+
return 0
257+
258+
if not check_clang_format(source_files, args.clang_format_executable, line_ranges):
259+
return 1
260+
261+
logging.info("clang-format check success.")
262+
return 0
263+
264+
265+
if __name__ == "__main__":
266+
sys.exit(main())

0 commit comments

Comments
 (0)