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.
5458INVALID_FLAGS : list [tuple [str , str ]] = []
@@ -79,6 +83,7 @@ def _reconfigure_io() -> None:
7983BRANCH_ENABLED = env_flag ("BRANCH" )
8084AUTHOR_NAME_ENABLED = env_flag ("AUTHOR_NAME" )
8185AUTHOR_EMAIL_ENABLED = env_flag ("AUTHOR_EMAIL" )
86+ FILES_ENABLED = env_flag ("FILES" )
8287DRY_RUN_ENABLED = env_flag ("DRY_RUN" )
8388JOB_SUMMARY_ENABLED = env_flag ("JOB_SUMMARY" )
8489PR_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.
446482AI_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+
9221043def 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+
11091255def _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+ )
17931972HOOK_HELP = (
17941973 f"**Next time.** The [pre-commit hook]({ LOCAL_HOOK_URL } ) runs the same "
17951974 "rules before you push."
17961975)
17971976
17981977
17991978def _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