mirror of
https://github.com/RT-Thread/rt-thread.git
synced 2026-08-23 18:38:38 +08:00
267 lines
7.9 KiB
Python
267 lines
7.9 KiB
Python
#
|
|
# Copyright (c) 2006-2026, RT-Thread Development Team
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
#
|
|
# Change Logs:
|
|
# Date Author Notes
|
|
# 2026-07-07 RT-Thread add clang-format CI check
|
|
#
|
|
|
|
import argparse
|
|
import fnmatch
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
SOURCE_EXTENSIONS = (
|
|
".c",
|
|
".h",
|
|
".cpp",
|
|
".hpp",
|
|
".cc",
|
|
".hh",
|
|
".C",
|
|
".H",
|
|
".cp",
|
|
".cxx",
|
|
".hxx",
|
|
".inc",
|
|
".inl",
|
|
".ipp",
|
|
".tpp",
|
|
".txx",
|
|
)
|
|
|
|
|
|
HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
|
|
|
|
def init_logger():
|
|
logging.basicConfig(level=logging.INFO, format="[%(filename)s %(lineno)d %(levelname)s] %(message)s ")
|
|
|
|
|
|
def run_git(args, check=True):
|
|
result = subprocess.run(["git"] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if check and result.returncode != 0:
|
|
raise RuntimeError(result.stderr.decode("utf-8", errors="replace"))
|
|
return result
|
|
|
|
|
|
def ensure_remote(remote, repo):
|
|
result = run_git(["remote", "get-url", remote], check=False)
|
|
if result.returncode == 0:
|
|
run_git(["remote", "set-url", remote, repo])
|
|
else:
|
|
run_git(["remote", "add", remote, repo])
|
|
|
|
|
|
def split_git_paths(output):
|
|
paths = []
|
|
for item in output.split(b"\0"):
|
|
if item:
|
|
paths.append(item.decode("utf-8", errors="surrogateescape"))
|
|
return paths
|
|
|
|
|
|
def get_changed_files(repo, branch, remote):
|
|
ensure_remote(remote, repo)
|
|
run_git(["fetch", "--no-tags", remote, branch])
|
|
|
|
merge_base = run_git(["merge-base", "FETCH_HEAD", "HEAD"]).stdout.decode("utf-8", errors="replace").strip()
|
|
if not merge_base:
|
|
raise RuntimeError("git merge-base FETCH_HEAD HEAD returned no commit")
|
|
|
|
diff_range = [merge_base, "HEAD"]
|
|
result = run_git(["diff", "--name-only", "-z", "--diff-filter=ACMR", "--no-renames"] + diff_range + ["--"])
|
|
return split_git_paths(result.stdout), diff_range
|
|
|
|
|
|
def merge_line_ranges(ranges):
|
|
if not ranges:
|
|
return []
|
|
|
|
merged = []
|
|
for start, end in sorted(ranges):
|
|
if not merged or start > merged[-1][1] + 1:
|
|
merged.append([start, end])
|
|
else:
|
|
merged[-1][1] = max(merged[-1][1], end)
|
|
return [(start, end) for start, end in merged]
|
|
|
|
|
|
def parse_changed_line_ranges(diff_text):
|
|
ranges = []
|
|
for line in diff_text.splitlines():
|
|
match = HUNK_HEADER.match(line)
|
|
if not match:
|
|
continue
|
|
|
|
start = int(match.group(1))
|
|
count = int(match.group(2) or "1")
|
|
if count == 0:
|
|
continue
|
|
ranges.append((start, start + count - 1))
|
|
|
|
return merge_line_ranges(ranges)
|
|
|
|
|
|
def get_changed_line_ranges(diff_range, files):
|
|
line_ranges = {}
|
|
for file_path in files:
|
|
result = run_git(["diff", "--unified=0", "--diff-filter=ACMR", "--no-renames"] + diff_range + ["--", file_path])
|
|
line_ranges[file_path] = parse_changed_line_ranges(result.stdout.decode("utf-8", errors="replace"))
|
|
return line_ranges
|
|
|
|
|
|
def normalize_path(path):
|
|
return path.as_posix().strip("/")
|
|
|
|
|
|
def match_path(path, pattern):
|
|
return fnmatch.fnmatchcase(path, pattern) or fnmatch.fnmatchcase("/" + path, pattern)
|
|
|
|
|
|
def ignore_pattern_matches(pattern, relative_file):
|
|
pattern = pattern.strip()
|
|
if not pattern or pattern.startswith("#"):
|
|
return False
|
|
|
|
pattern = pattern.lstrip("/")
|
|
dir_only = pattern.endswith("/")
|
|
pattern = pattern.rstrip("/")
|
|
if not pattern:
|
|
return False
|
|
|
|
relative_file = relative_file.strip("/")
|
|
if dir_only:
|
|
parts = relative_file.split("/")
|
|
dirs = ["/".join(parts[:index]) for index in range(1, len(parts))]
|
|
return match_path(relative_file, pattern) or any(match_path(directory, pattern) for directory in dirs)
|
|
|
|
if "/" not in pattern:
|
|
return any(fnmatch.fnmatchcase(part, pattern) for part in relative_file.split("/"))
|
|
|
|
return match_path(relative_file, pattern)
|
|
|
|
|
|
def is_ignored_by_clang_format(root, file_path):
|
|
absolute_file = (root / file_path).resolve()
|
|
try:
|
|
relative_to_root = absolute_file.relative_to(root)
|
|
except ValueError:
|
|
return False
|
|
|
|
current_dir = absolute_file.parent
|
|
ignore_files = []
|
|
while True:
|
|
ignore_file = current_dir / ".clang-format-ignore"
|
|
if ignore_file.exists():
|
|
ignore_files.append(ignore_file)
|
|
if current_dir == root:
|
|
break
|
|
current_dir = current_dir.parent
|
|
|
|
for ignore_file in reversed(ignore_files):
|
|
try:
|
|
ignore_dir = ignore_file.parent.relative_to(root)
|
|
if str(ignore_dir) == ".":
|
|
relative_file = normalize_path(relative_to_root)
|
|
else:
|
|
relative_file = normalize_path(relative_to_root.relative_to(ignore_dir))
|
|
except ValueError:
|
|
continue
|
|
with open(ignore_file, "r", encoding="utf-8") as ignore:
|
|
for line in ignore:
|
|
if ignore_pattern_matches(line, relative_file):
|
|
logging.info("ignore file by %s: %s", ignore_file.relative_to(root).as_posix(), file_path)
|
|
return True
|
|
return False
|
|
|
|
|
|
def filter_source_files(root, files):
|
|
source_files = []
|
|
for file_path in files:
|
|
path = root / file_path
|
|
if not path.is_file():
|
|
continue
|
|
if not file_path.endswith(SOURCE_EXTENSIONS):
|
|
continue
|
|
if is_ignored_by_clang_format(root, Path(file_path)):
|
|
continue
|
|
source_files.append(file_path)
|
|
return source_files
|
|
|
|
|
|
def check_clang_format(files, clang_format, line_ranges=None):
|
|
failed_files = []
|
|
for file_path in files:
|
|
invocation = [clang_format, "--dry-run", "--Werror", "--style=file"]
|
|
if line_ranges is not None:
|
|
ranges = line_ranges.get(file_path, [])
|
|
if not ranges:
|
|
logging.info("skip clang-format, no changed lines -> %s", file_path)
|
|
continue
|
|
for start, end in ranges:
|
|
invocation.append("--lines={}:{}".format(start, end))
|
|
|
|
invocation.append(file_path)
|
|
logging.info("check clang-format -> %s", file_path)
|
|
result = subprocess.run(
|
|
invocation,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
universal_newlines=True,
|
|
)
|
|
if result.stdout:
|
|
sys.stdout.write(result.stdout)
|
|
if result.stderr:
|
|
sys.stderr.write(result.stderr)
|
|
if result.returncode != 0:
|
|
failed_files.append(file_path)
|
|
|
|
if failed_files:
|
|
logging.error("clang-format check failed, please run clang-format on the files above.")
|
|
return False
|
|
return True
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Check changed source files with clang-format.")
|
|
parser.add_argument("--repo", default="https://github.com/RT-Thread/rt-thread")
|
|
parser.add_argument("--branch", default="master")
|
|
parser.add_argument("--remote", default="rtt_repo")
|
|
parser.add_argument("--clang-format-executable", default="clang-format")
|
|
parser.add_argument("files", nargs="*", help="files to check; git diff is used when omitted")
|
|
args = parser.parse_args()
|
|
|
|
init_logger()
|
|
root = Path.cwd().resolve()
|
|
if args.files:
|
|
files = args.files
|
|
line_ranges = None
|
|
else:
|
|
files, diff_range = get_changed_files(args.repo, args.branch, args.remote)
|
|
line_ranges = get_changed_line_ranges(diff_range, files)
|
|
|
|
source_files = filter_source_files(root, files)
|
|
if line_ranges is not None:
|
|
line_ranges = {file_path: line_ranges.get(file_path, []) for file_path in source_files}
|
|
|
|
if not source_files:
|
|
logging.warning("There are no source files to check format.")
|
|
return 0
|
|
|
|
if not check_clang_format(source_files, args.clang_format_executable, line_ranges):
|
|
return 1
|
|
|
|
logging.info("clang-format check success.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|