Skip to content

Commit 73fb8a8

Browse files
committed
feat: add a files input that checks the files each commit changes
With files: true the action runs commit-check --files on every commit of the pull request (the HEAD commit outside one), so the [files] rules of cchk.toml apply in CI: file size, prohibited paths, path length. Off by default. Each commit gets a row in a new "Files" group of the report. A finding names the path and the commit that has to change, in the step log, the annotations, the diff and the "How to fix" prompt. The files a commit changes are a diff against its parent. A commit the checkout holds without its parent, the edge of a shallow clone, is reported as skipped with a warning that names fetch-depth: 0, since it would otherwise answer for every file in the repository. With no rule set in [files], one skipped row says so.
1 parent c5dada8 commit 73fb8a8

4 files changed

Lines changed: 746 additions & 15 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ runners need a few tools — see [Good to know](#good-to-know).
7979
| `branch` | `true` | Check the branch name against [Conventional Branch](https://conventionalbranch.org/) |
8080
| `author-name` | `false` | Check each commit's author name |
8181
| `author-email` | `false` | Check each commit's author email |
82+
| `files` | `false` | Check the files each commit changes against the `[files]` rules of `cchk.toml` (size, prohibited paths, path length). Needs `fetch-depth: 0`: a commit the clone holds without its parent is skipped |
8283
| `pr-title` | `false` | Check the pull request title against Conventional Commits — the one that matters for squash merges. Pull request events only |
8384
| `message-ai-attribution` | `false` | Check every commit against the AI rules of `cchk.toml` (`ai_attribution`) only, author included, without the format rules. Takes the place of `message` |
8485
| `pr-body-ai-attribution` | `false` | Check the pull request description against the AI rules of `cchk.toml` (`ai_attribution`) only. Pull request events only |

‎action.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ inputs:
2121
description: check committer author email
2222
required: false
2323
default: false
24+
files:
25+
description: check the files each commit changes against the [files] rules in cchk.toml (size, prohibited paths, path length); needs fetch-depth 0
26+
required: false
27+
default: false
2428
dry-run:
2529
description: report failures (summary, PR comment, annotations as warnings) but always exit 0
2630
required: false
@@ -122,6 +126,7 @@ runs:
122126
BRANCH: ${{ inputs.branch }}
123127
AUTHOR_NAME: ${{ inputs.author-name }}
124128
AUTHOR_EMAIL: ${{ inputs.author-email }}
129+
FILES: ${{ inputs.files }}
125130
DRY_RUN: ${{ inputs.dry-run }}
126131
JOB_SUMMARY: ${{ inputs.job-summary }}
127132
PR_COMMENTS: ${{ inputs.pr-comments }}

‎main.py‎

Lines changed: 210 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@
4949
}
5050

5151

52+
#: Label of the scopes that check the files a commit changes (``files: true``).
53+
FILES_LABEL = "Files"
54+
55+
5256
#: Boolean inputs whose value was neither true nor false, as ``(name, value)``.
5357
#: ``main`` fails the step on them before any check runs.
5458
INVALID_FLAGS: list[tuple[str, str]] = []
@@ -79,6 +83,7 @@ def _reconfigure_io() -> None:
7983
BRANCH_ENABLED = env_flag("BRANCH")
8084
AUTHOR_NAME_ENABLED = env_flag("AUTHOR_NAME")
8185
AUTHOR_EMAIL_ENABLED = env_flag("AUTHOR_EMAIL")
86+
FILES_ENABLED = env_flag("FILES")
8287
DRY_RUN_ENABLED = env_flag("DRY_RUN")
8388
JOB_SUMMARY_ENABLED = env_flag("JOB_SUMMARY")
8489
PR_COMMENTS_ENABLED = env_flag("PR_COMMENTS")
@@ -259,6 +264,7 @@ def log_env_vars():
259264
"BRANCH",
260265
"AUTHOR_NAME",
261266
"AUTHOR_EMAIL",
267+
"FILES",
262268
"DRY_RUN",
263269
"JOB_SUMMARY",
264270
"PR_COMMENTS",
@@ -442,6 +448,36 @@ def skipped_author_scope(flag: str) -> ScopeResult:
442448
)
443449

444450

451+
#: Why a files scope checked nothing.
452+
FILES_UNCONFIGURED = "no rule is set in the [files] section of the config"
453+
FILES_NOT_IN_CLONE = "the checkout lacks the commit or its parent"
454+
FILES_UNLISTED = "the pull request's commits could not be listed"
455+
FILES_NOTHING_CHANGED = "the commit adds or changes no file"
456+
457+
458+
def skipped_files_scope(reason: str, sha: str = "") -> ScopeResult:
459+
"""A scope recording that the files of a commit, or of all, went unchecked.
460+
461+
Reported as ``skip``, never as a pass: nothing was validated.
462+
"""
463+
return ScopeResult(
464+
label=FILES_LABEL,
465+
sha=sha,
466+
checks=[
467+
{
468+
"rule_id": "",
469+
"check": "files",
470+
"status": "skip",
471+
"value": "",
472+
"error": "",
473+
"suggest": "",
474+
"docs_url": "",
475+
"reason": reason,
476+
}
477+
],
478+
)
479+
480+
445481
#: Why an AI-only scope ran no rule: under the default policy there is none to run.
446482
AI_POLICY_IGNORED = 'ai_attribution is "ignore": set it to "forbid" or "disclose"'
447483

@@ -919,6 +955,91 @@ def run_pr_author_checks(
919955
]
920956

921957

958+
def _shallow_boundary() -> set[str]:
959+
"""The commits a shallow clone holds without their parents.
960+
961+
Git lists them, one hash per line, in the repository's ``shallow`` file;
962+
a complete clone has none. Such a commit looks like a root commit, so a
963+
diff of it names every file in the repository as changed.
964+
"""
965+
try:
966+
located = subprocess.run(
967+
["git", "rev-parse", "--git-path", "shallow"],
968+
capture_output=True,
969+
encoding="utf-8",
970+
check=False,
971+
)
972+
if located.returncode != 0:
973+
return set()
974+
with open(located.stdout.strip(), encoding="utf-8") as f:
975+
return set(f.read().split())
976+
except OSError:
977+
return set()
978+
979+
980+
def run_files_checks(shas: list[str]) -> list[ScopeResult]:
981+
"""Check the files each commit changes, via ``commit-check --files --rev``.
982+
983+
One scope per commit, so a finding names the commit that has to change:
984+
a file added in one commit and removed in the next is still in the
985+
history. A commit is only checked when the checkout holds it together
986+
with its parent, since the files it changes are a diff against that
987+
parent. The others are reported as skipped, never guessed at.
988+
989+
With no rule set in ``[files]`` the CLI runs none, for any commit, and
990+
one skipped scope says so in place of a row of empty passes.
991+
"""
992+
boundary = _shallow_boundary()
993+
readable = {
994+
sha for sha in shas if sha and sha not in boundary and _rev_resolves(sha)
995+
}
996+
if not readable:
997+
# Whether a rule is set at all, asked of HEAD: only the list of
998+
# checks is read, never a verdict on HEAD itself.
999+
probe = check_scope(FILES_LABEL, ["--files"])
1000+
if not (probe.checks or probe.raw_text):
1001+
return [skipped_files_scope(FILES_UNCONFIGURED)]
1002+
warn_shallow_checkout(
1003+
"The checkout holds no commit to check together with its parent",
1004+
"the files check was skipped",
1005+
)
1006+
return [skipped_files_scope(FILES_NOT_IN_CLONE)]
1007+
1008+
results: list[ScopeResult] = []
1009+
for sha in shas:
1010+
if sha not in readable:
1011+
results.append(skipped_files_scope(FILES_NOT_IN_CLONE, sha))
1012+
continue
1013+
scope = check_scope(FILES_LABEL, ["--files", "--rev", sha], sha=sha)
1014+
if not (scope.checks or scope.raw_text):
1015+
return [skipped_files_scope(FILES_UNCONFIGURED)]
1016+
if scope.status == "skip" and not scope.skip_reason:
1017+
# The file rules skip a commit that only deletes, or merges
1018+
# without bringing a file along, and do not say why.
1019+
for check in scope.checks:
1020+
check["reason"] = FILES_NOTHING_CHANGED
1021+
results.append(scope)
1022+
missing = sum(1 for sha in shas if sha not in readable)
1023+
if missing:
1024+
warn_shallow_checkout(
1025+
f"{missing} of {len(shas)} commits are not in the checkout "
1026+
"together with their parent",
1027+
"their files were not checked",
1028+
)
1029+
return results
1030+
1031+
1032+
def run_pr_files_checks(pr_commits: list[Commit]) -> list[ScopeResult]:
1033+
"""Check the files of every commit of the pull request."""
1034+
if not pr_commits:
1035+
warn_shallow_checkout(
1036+
"Could not list the pull request's commits",
1037+
"the files check was skipped",
1038+
)
1039+
return [skipped_files_scope(FILES_UNLISTED)]
1040+
return run_files_checks([sha for sha, _msg in pr_commits])
1041+
1042+
9221043
def check_empty_message(
9231044
label: str, sha: str, env: dict[str, str] | None
9241045
) -> ScopeResult:
@@ -976,7 +1097,9 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
9761097
scope is reported)
9771098
3. Individual PR commit messages (when ``message: true`` and in a PR event;
9781099
AI rules only with ``message-ai-attribution: true``)
979-
4. Author checks on every PR commit's author, then the remaining checks
1100+
4. The files each PR commit changes (when ``files: true``; outside a PR,
1101+
the files of the HEAD commit)
1102+
5. Author checks on every PR commit's author, then the remaining checks
9801103
(branch; outside a PR, the HEAD commit and its author)
9811104
9821105
Outside of a PR event all enabled checks are handed to the CLI at once.
@@ -1021,13 +1144,23 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
10211144
"Could not list the pull request's commits", "only HEAD was checked"
10221145
)
10231146

1024-
# ---- 4. Remaining checks (branch, author, etc.) -----------------------
10251147
# Outside a PR, check the HEAD commit message directly.
10261148
if "--message" in args:
10271149
results.append(
10281150
keep_ai_rules(check_scope("Commit message", ["--message"], sha=head_sha()))
10291151
)
10301152
args = [a for a in args if a != "--message"]
1153+
1154+
# ---- 4. Files of each commit ------------------------------------------
1155+
if FILES_ENABLED:
1156+
if is_pr_event():
1157+
if pr_commits is None:
1158+
pr_commits = get_pr_commit_messages()
1159+
results.extend(run_pr_files_checks(pr_commits))
1160+
else:
1161+
results.extend(run_files_checks([head_sha()]))
1162+
1163+
# ---- 5. Remaining checks (branch, author, etc.) -----------------------
10311164
rev = None
10321165
author_flags = [flag for flag in args if flag in AUTHOR_FLAGS]
10331166
if is_pr_event() and author_flags:
@@ -1106,6 +1239,19 @@ def _rule_ref(check: dict[str, str]) -> str:
11061239
return f"[{label}]({docs_url})" if docs_url else label
11071240

11081241

1242+
def _names_a_commit(scope: ScopeResult) -> bool:
1243+
"""Whether the scope is one commit's: its message, or the files it changes."""
1244+
return bool(scope.sha) and (
1245+
scope.label.startswith("Commit") or scope.label == FILES_LABEL
1246+
)
1247+
1248+
1249+
def _found(check: dict[str, str]) -> str:
1250+
"""First line of what a rule reports it found."""
1251+
value = str(check.get("value") or "").strip()
1252+
return value.splitlines()[0].strip() if value else ""
1253+
1254+
11091255
def _scope_group(label: str) -> str:
11101256
"""Group name for a scope label, used to fold the step log output."""
11111257
if label in ("PR title", "PR description"):
@@ -1167,6 +1313,12 @@ def _first_value(scope: ScopeResult) -> str:
11671313
"""
11681314
if scope.label == "PR description":
11691315
return ""
1316+
if scope.label == FILES_LABEL:
1317+
# Each file rule names its own finding, a path and what is wrong
1318+
# with it. The first finding stands for the scope; with none, the
1319+
# count of files a passing rule reports.
1320+
checks = [*scope.failures, *scope.warnings] or scope.checks
1321+
return next((_found(check) for check in checks if _found(check)), "")
11701322
values = [scope.text, _message_value(scope)]
11711323
values.extend(
11721324
str(check.get("value") or "")
@@ -1265,12 +1417,17 @@ def _instruction(scope: ScopeResult, check: dict[str, str]) -> str:
12651417
return ", and ".join(parts) or _advice(check)
12661418

12671419

1268-
def _target(scope: ScopeResult, instructions: list[str], label: str) -> str:
1420+
def _target(
1421+
scope: ScopeResult, instructions: list[str], label: str, value: str | None = None
1422+
) -> str:
12691423
"""Name what an instruction is about: ``Commit 2/3 (5584f46) "bad msg"``.
12701424
1271-
The quoted value is left out when an instruction already quotes it.
1425+
The quoted value is left out when an instruction already quotes it. It
1426+
is the scope's own unless ``value`` names another: a files scope holds
1427+
one finding per rule, each about a path of its own.
12721428
"""
1273-
value = _first_value(scope)
1429+
if value is None:
1430+
value = _first_value(scope)
12741431
if value and not any(f'"{value}"' in text for text in instructions):
12751432
return f'{label} "{value}"'
12761433
return label
@@ -1340,7 +1497,7 @@ def _row_label(scope: ScopeResult) -> str:
13401497
The group header already says "Commits" or "Author", so the row only
13411498
needs what tells it apart from its neighbours.
13421499
"""
1343-
if scope.sha and scope.label.startswith("Commit"):
1500+
if _names_a_commit(scope):
13441501
return scope.sha[:7]
13451502
_group, _, name = scope.label.partition(" ")
13461503
return name or scope.label.lower()
@@ -1468,7 +1625,9 @@ def _annotation_body(scope: ScopeResult, check: dict[str, str]) -> str:
14681625
for whoever expands it.
14691626
"""
14701627
instruction = _instruction(scope, check)
1471-
headline = f"{_target(scope, [instruction], scope.display_label)}: {instruction}"
1628+
found = _found(check) if scope.label == FILES_LABEL else None
1629+
target = _target(scope, [instruction], scope.display_label, found)
1630+
headline = f"{target}: {instruction}"
14721631
error = _plain_error(check)
14731632
return headline if error == instruction else f"{headline}\n{error}"
14741633

@@ -1701,8 +1860,21 @@ def _hunk(scope: ScopeResult, status: str) -> list[str]:
17011860
literal text to offer. GitHub colours all three in a ``diff`` block,
17021861
which is the only colour a comment or a job summary can carry.
17031862
"""
1704-
is_commit = bool(scope.sha) and scope.label.startswith("Commit")
1705-
lines = [f"@@ {scope.sha[:7] if is_commit else scope.label} @@"]
1863+
if scope.sha and scope.label.startswith("Commit"):
1864+
where = scope.sha[:7]
1865+
elif scope.sha and scope.label == FILES_LABEL:
1866+
where = f"{scope.sha[:7]} files"
1867+
else:
1868+
where = scope.label
1869+
lines = [f"@@ {where} @@"]
1870+
if scope.label == FILES_LABEL and scope.checks:
1871+
# One finding per rule, each about a path of its own: the path it
1872+
# found, then what to do about it.
1873+
for check in _findings(scope, status):
1874+
if _found(check):
1875+
lines.append(f"- {_found(check)}")
1876+
lines.append(f"# {_advice(check)}")
1877+
return lines
17061878
value = _first_value(scope)
17071879
removed: list[str] = []
17081880
added: list[str] = []
@@ -1759,11 +1931,13 @@ def _diff_block(results: list[ScopeResult], status: str = "fail") -> list[str]:
17591931
rest = [line for hunk in hunks[shown:] for line in hunk]
17601932
lines += ["", "<details>", f"<summary>and {len(hunks) - shown} more</summary>"]
17611933
lines += ["", *_fenced("diff", rest), "", "</details>"]
1762-
commits = [
1763-
f"[`{scope.sha[:7]}`]({_commit_url(scope.sha)})"
1764-
for scope in scopes[:shown]
1765-
if scope.label.startswith("Commit") and _commit_url(scope.sha)
1766-
]
1934+
commits = list(
1935+
dict.fromkeys(
1936+
f"[`{scope.sha[:7]}`]({_commit_url(scope.sha)})"
1937+
for scope in scopes[:shown]
1938+
if _names_a_commit(scope) and _commit_url(scope.sha)
1939+
)
1940+
)
17671941
rules = list(
17681942
dict.fromkeys(
17691943
_rule_ref(check) for scope in scopes for check in _findings(scope, status)
@@ -1790,16 +1964,32 @@ def _diff_block(results: list[ScopeResult], status: str = "fail") -> list[str]:
17901964
"`git config user.email`), then `git commit --amend --reset-author` "
17911965
"and force-push."
17921966
)
1967+
FILES_HELP = (
1968+
"**Files.** Amend the commit that changed the file (`git rebase -i`, or "
1969+
"`git commit --amend` for the last one), then force-push. A later commit "
1970+
"that removes the file leaves it in the history."
1971+
)
17931972
HOOK_HELP = (
17941973
f"**Next time.** The [pre-commit hook]({LOCAL_HOOK_URL}) runs the same "
17951974
"rules before you push."
17961975
)
17971976

17981977

17991978
def _agent_prompt(failing: list[ScopeResult]) -> list[str]:
1800-
"""A prompt to paste into an AI coding agent, one line per failing scope."""
1979+
"""A prompt to paste into an AI coding agent, one line per failing scope.
1980+
1981+
A files scope gets a line per finding: each is about a path of its own.
1982+
"""
18011983
lines = [AGENT_PROMPT]
18021984
for scope in failing:
1985+
if scope.label == FILES_LABEL:
1986+
label = f"Files of commit {scope.sha[:7]}" if scope.sha else scope.label
1987+
for check in scope.failures:
1988+
todo = [_advice(check)]
1989+
lines.append(
1990+
f"- {_target(scope, todo, label, _found(check))}: {todo[0]}"
1991+
)
1992+
continue
18031993
todo = list(dict.fromkeys(_instruction(scope, c) for c in scope.failures))
18041994
is_commit = bool(scope.sha) and scope.label.startswith("Commit")
18051995
label = f"Commit {scope.sha[:7]}" if is_commit else scope.label
@@ -1845,6 +2035,8 @@ def _how_to_fix(results: list[ScopeResult]) -> list[str]:
18452035
many = "it" if len(commits) == 1 else "them"
18462036
parts.append([f"**Commit messages.** Reword {many}, then force-push:"])
18472037
parts.append(_fenced("bash", _reword_commands(commits)))
2038+
if FILES_LABEL in labels:
2039+
parts.append([FILES_HELP])
18482040
if any(label.startswith("Author") for label in labels):
18492041
parts.append([AUTHOR_HELP])
18502042
if "Branch" in labels:
@@ -2021,6 +2213,9 @@ def _all_checks(results: list[ScopeResult]) -> list[str]:
20212213
# folded; passing scopes live in the listing.
20222214
# - A commit is named by its short hash on every surface; the label
20232215
# ("Commit 1/11") stays bare in the `result` output.
2216+
# - With `files: true` each commit also gets a row in a "Files" group, for the
2217+
# files it changes. Its hunk reads `@@ 5584f46 files @@`, with a red line per
2218+
# path a rule found and the rule's advice under it.
20242219
# - One check is one thing that was checked — a commit message, the branch, the
20252220
# author — not one rule evaluation. The totals therefore match the number of
20262221
# ✔/✖ rows the reader can count in the listing, and do not grow with the

0 commit comments

Comments
 (0)