From af2dc28f0966edcb489bcc39a70285a6479fc406 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 11:56:26 -0700 Subject: [PATCH 1/2] Add repo-gate-exclude-globs Input to Narrow sha-pin/eol-coverage Scans Fixes #957. `repo_gate.py`'s `tracked()` read every git-tracked path with no way for a caller to narrow it, so `sha-pin` and `eol-coverage` (both of which read that list) had no per-repo exclusion point. Blog vendors the PaperMod theme under `themes/PaperMod/` as plain tracked files, byte-identical to upstream per `themes/README.md`'s documented invariant, and PaperMod's own CI workflows pin actions by floating tag, which Blog does not author and does not locally edit. Adopting the hub's `validate-task.yml` therefore fails `sha-pin` on all 5 of PaperMod's own pins every run, with no way to scope them out without breaking the byte-identical invariant. ## The fix The same shape `markdown-exclude-globs` (#935) already established for the Lint Markdown step, applied to the repo gate: - `repo_gate.py`: `tracked()` takes an optional `exclude` list, turned into `:!` pathspecs appended to `git ls-files` after `--`. The CLI gained a repeatable `--exclude PATTERN` argument, and `main()` prints a note naming what was excluded, since a check that quietly scans less than its own docstring claims is exactly the silent narrowing this script's own `NOTES` convention exists to surface. - `.github/actions/repo-gate/action.yml`: a new `exclude-globs` input, newline-separated, turned into repeated `--exclude` arguments by the composite step's own shell before invoking `repo_gate.py`. - `.github/workflows/validate-task.yml`: a new `repo-gate-exclude-globs` `workflow_call` input, threaded straight through to the action's `exclude-globs`. Empty by default, so the default caller excludes nothing. - `docs/reusable-workflows.md` "Adopting the Gates": documents the new input with Blog's own PaperMod case, the same way the markdown section above it documents `markdown-exclude-globs`. Unlike `markdown-exclude-globs`, a line here is never negated: it is always a pathspec to drop, so `themes/PaperMod/**` excludes rather than `!themes/PaperMod/**`. ## Verified Added `TestExcludeGlobs` (a fresh git repo per case, so the exclude is proven against a real subtree rather than one this repo happens to carry) and a repo-level floor test excluding this repo's own two `workflows/*.yml`-matching directories down to zero. Exercised the composite action's own shell logic directly for both the multi-pattern and empty-input cases. Ran `--exclude` against this checkout directly, confirming `sha-pin` resolves 0 pins once every workflow directory is excluded. Full test suite (804 tests), ruff check and format, mypy, `repo_gate.py` against this checkout, `prose_lint.py --diff origin/develop`, and `docker_lint.py` (actionlint, markdownlint, editorconfig-checker) all pass clean. --- .github/actions/repo-gate/action.yml | 19 +++++++- .github/actions/repo-gate/repo_gate.py | 31 ++++++++++-- .github/workflows/validate-task.yml | 11 ++++- docs/reusable-workflows.md | 10 ++++ scripts/tests/test_repo_gate.py | 65 ++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 5 deletions(-) diff --git a/.github/actions/repo-gate/action.yml b/.github/actions/repo-gate/action.yml index 4e7044d3..0caf423c 100644 --- a/.github/actions/repo-gate/action.yml +++ b/.github/actions/repo-gate/action.yml @@ -1,6 +1,16 @@ name: Repository gate description: Check action pins and line-ending contracts. +inputs: + exclude-globs: + description: >- + Newline-separated git pathspec patterns (for example 'themes/PaperMod/**'), each turned + into a `--exclude` argument for repo_gate.py so a caller vendoring a subtree it does not + author can scope every check's tracked-file scan out of it. Empty by default: nothing is + excluded, and every check keeps scanning the whole repository. + required: false + default: '' + runs: using: composite steps: @@ -8,4 +18,11 @@ runs: shell: bash env: GH_TOKEN: ${{ github.token }} - run: python3 "$GITHUB_ACTION_PATH/repo_gate.py" + EXCLUDE_GLOBS: ${{ inputs.exclude-globs }} + run: | + set -Eeuo pipefail + args=() + while IFS= read -r glob; do + [ -n "$glob" ] && args+=(--exclude "$glob") + done <<< "$EXCLUDE_GLOBS" + python3 "$GITHUB_ACTION_PATH/repo_gate.py" "${args[@]}" diff --git a/.github/actions/repo-gate/repo_gate.py b/.github/actions/repo-gate/repo_gate.py index 315d159d..28725d6f 100755 --- a/.github/actions/repo-gate/repo_gate.py +++ b/.github/actions/repo-gate/repo_gate.py @@ -115,8 +115,18 @@ def pin_resolves(nwo: str, sha: str, cache: dict[tuple[str, str], bool | None]) return cache[key] -def tracked(root: Path) -> list[str]: - out = sh("git", "-C", str(root), "ls-files") +def tracked(root: Path, exclude: list[str] | None = None) -> list[str]: + """Every git-tracked path, narrowed by `exclude` pathspec patterns where the caller gives any. + + Each pattern is turned into a `:!` exclude pathspec and appended after `--`, so a + caller vendoring a subtree it does not author (per GOVERNANCE.md's carry-versus-reach test) + can scope every check that reads this list out of that subtree without touching the checks + themselves. Additive only: an empty or absent `exclude` scans exactly what it always has. + """ + args = ["git", "-C", str(root), "ls-files"] + if exclude: + args += ["--", *(f":!{pattern}" for pattern in exclude)] + out = sh(*args) return [l for l in out.split("\n") if l] @@ -341,12 +351,27 @@ def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser() ap.add_argument("--root", default=".") ap.add_argument("--check", action="append", choices=sorted(CHECKS)) + ap.add_argument( + "--exclude", + action="append", + default=[], + metavar="PATTERN", + help="git pathspec pattern to exclude from every check's tracked-file scan " + "(for example 'themes/PaperMod/**'); repeatable", + ) a = ap.parse_args(argv) root = Path(a.root).resolve() - files = tracked(root) + files = tracked(root, a.exclude) if not files: print(f"{root}: not a git repo or no tracked files", file=sys.stderr) return 2 + if a.exclude: + # Printed once, ahead of every check, since the narrowing applies to the whole scan rather than to one check. + # The docstring's rule is that a check saying it did less than its name prints that, rather than leaving it to be inferred from a clean line. + print( + f"note: {len(a.exclude)} exclude pattern(s) narrowed the tracked-file scan: " + f"{', '.join(a.exclude)}" + ) total = 0 for name in a.check or sorted(CHECKS): diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 0a1ded2b..47efcfd7 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -3,7 +3,7 @@ name: Validate task # The fleet validation gate, hosted here once and reached by every repo's test-pull-request stub and its own publish-release stub. # Three jobs: lint (the fleet doc-lint block plus language lint by tree detection, the prose gate, and the repo gate), unit-test (a generic dotnet test or uv run pytest, skipped where the caller has no test project), and validate (the validate hook, a repo's own domain checks such as an ESPHome compile, a Hugo build, a KiCad ERC, a codegen-drift check, or PowerShell tests). # No permissions beyond contents: read where a job needs one, since every job here only checks out and reads. -# No required inputs, markdown-exclude-globs is the one optional input, and CODECOV_TOKEN is the one optional secret, since coverage upload is best-effort. +# No required inputs, markdown-exclude-globs and repo-gate-exclude-globs are the two optional inputs, and CODECOV_TOKEN is the one optional secret, since coverage upload is best-effort. # Hub-owned gates and default hooks resolve through $/ at the reusable workflow's commit, so each implementation is reproducible against the caller's released pin without a second checkout. on: workflow_call: @@ -14,6 +14,12 @@ on: required: false type: string default: '' + # Threaded to the repo-gate composite action's own exclude-globs input, unvalidated. + repo-gate-exclude-globs: + description: Git pathspec patterns to exclude, one per line (for example 'themes/PaperMod/**'), narrowing what sha-pin/eol/eol-coverage scan via repo_gate.py's --exclude. + required: false + type: string + default: '' secrets: CODECOV_TOKEN: required: false @@ -243,8 +249,11 @@ jobs: with: base: ${{ github.event.pull_request.base.sha }} + # An empty repo-gate-exclude-globs leaves the action's own input at its default, so the default caller excludes nothing. - name: Check repo gates step uses: $/.github/actions/repo-gate + with: + exclude-globs: ${{ inputs.repo-gate-exclude-globs }} # No job-level if: here, since GitHub Actions does not evaluate hashFiles in a job condition, only a step one. # Every step below carries its own tree-detection guard instead. diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 83bc704b..d34cb3f7 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -365,6 +365,16 @@ A repo that vendors a theme or imports content it does not author narrows the Li `validate-task.yml` appends each line after `**/*.md` in the Lint Markdown step's own `globs:` block, unvalidated. A negated glob excludes, the intended use, but a non-negated one adds to what is linted rather than narrowing it. +The repo gate's `sha-pin` and `eol-coverage` checks read the same tracked-file list and hit the same wall for a vendored subtree carrying its own CI: PaperMod's own workflows pin actions by floating tag, which Blog does not author and, per `themes/README.md`'s byte-identical-to-upstream invariant, does not locally edit either. `repo-gate-exclude-globs` narrows that scan the same way, one git pathspec pattern per line: + +```yaml + with: + repo-gate-exclude-globs: | + themes/PaperMod/** +``` + +`validate-task.yml` passes each line straight through to the `repo-gate` composite action's own `exclude-globs` input, which turns it into a `--exclude` argument for `repo_gate.py`. Unlike `markdown-exclude-globs`, a line here is never negated: it is always a pathspec to drop from `git ls-files`, so `themes/PaperMod/**` excludes rather than `!themes/PaperMod/**`. + ## Adopting the Pure Functions Neither `get-version-task.yml` nor `publish-plan-task.yml` has a caller-stub snippet of its own, since a caller reaching either one is a job inside a repo's own `publish-release.yml` or a future `build-release-task.yml`, not a standalone top-level workflow. A repo whose publisher reads NBGV's version outputs directly, without carrying the whole release orchestrator, reaches `get-version-task.yml` by pin in place of its own copy: diff --git a/scripts/tests/test_repo_gate.py b/scripts/tests/test_repo_gate.py index a0d2ed10..6be05c40 100755 --- a/scripts/tests/test_repo_gate.py +++ b/scripts/tests/test_repo_gate.py @@ -489,6 +489,19 @@ def test_the_sha_pin_scan_is_not_vacuous(self) -> None: """A workflow glob that matched nothing would print `0 issue(s)` and read as clean.""" self.assertGreaterEqual(len(repo_gate.workflow_files(repo_gate.tracked(REPO))), 4) + def test_excluding_every_workflow_empties_the_sha_pin_scan(self) -> None: + """Proves the `--exclude` plumbing actually reaches sha-pin's own tracked-file scan. + + Two directories carry a `workflows/*.yml` path in this repo: the real workflows and the + `catalog/snippets/workflows/` examples the audit also scans, so both are excluded. + """ + self.assertEqual( + [], + repo_gate.workflow_files( + repo_gate.tracked(REPO, [".github/workflows/**", "catalog/snippets/workflows/**"]) + ), + ) + def test_the_representative_eol_check_runs_against_this_repo(self) -> None: self.assertEqual([], repo_gate.check_eol_coverage(REPO, repo_gate.tracked(REPO))) @@ -531,6 +544,58 @@ def test_an_unreadable_workflow_is_skipped_rather_than_raising(self) -> None: self.assertEqual([], repo_gate.check_sha_pin(REPO, [".github/workflows/absent.yml"])) +class TestExcludeGlobs(unittest.TestCase): + """A caller vendoring a subtree it does not author needs `tracked()` narrowed, not re-derived. + + Own git repo rather than REPO, so a case proves the exclude reaches a real subtree it built + rather than one this repo happens to carry today. + """ + + def setUp(self) -> None: + if shutil.which("git") is None: + self.skipTest("git absent, so ls-files cannot be asked") + repo_gate.NOTES.clear() + self.tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) + subprocess.run(["git", "-C", str(self.tmp), "init", "-q", "."], check=True) + for rel in ("kept.py", "vendor/upstream.py", "vendor/nested/deep.py"): + p = self.tmp / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("pass\n", encoding="utf-8") + subprocess.run(["git", "-C", str(self.tmp), "add", "-A"], check=True, capture_output=True) + + def test_a_pathspec_pattern_drops_the_matching_subtree(self) -> None: + self.assertEqual( + ["kept.py", "vendor/nested/deep.py", "vendor/upstream.py"], + sorted(repo_gate.tracked(self.tmp)), + ) + self.assertEqual(["kept.py"], repo_gate.tracked(self.tmp, ["vendor/**"])) + + def test_no_exclude_scans_exactly_as_before(self) -> None: + """`exclude=[]` and `exclude=None` are both the CLI's own no-flag default.""" + baseline = repo_gate.tracked(self.tmp) + self.assertEqual(baseline, repo_gate.tracked(self.tmp, [])) + self.assertEqual(baseline, repo_gate.tracked(self.tmp, None)) + + def test_every_pattern_given_is_applied(self) -> None: + self.assertEqual([], repo_gate.tracked(self.tmp, ["kept.py", "vendor/**"])) + + def test_the_cli_wires_the_exclude_flag_through_to_tracked(self) -> None: + with contextlib.redirect_stdout(io.StringIO()) as out: + code = repo_gate.main( + ["--root", str(self.tmp), "--check", "sha-pin", "--exclude", "vendor/**"] + ) + self.assertEqual(0, code) + self.assertIn( + "note: 1 exclude pattern(s) narrowed the tracked-file scan: vendor/**", + out.getvalue(), + ) + + def test_the_narrowing_note_is_silent_when_nothing_was_excluded(self) -> None: + with contextlib.redirect_stdout(io.StringIO()) as out: + repo_gate.main(["--root", str(self.tmp), "--check", "sha-pin"]) + self.assertNotIn("narrowed the tracked-file scan", out.getvalue()) + + class TestHarness(unittest.TestCase): def test_this_module_collects_a_plausible_number_of_cases(self) -> None: loaded = unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__]) From 1a852f3c2cdd4b81abcd9c070d4f297aa437fa26 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 12:08:11 -0700 Subject: [PATCH 2/2] Fix Review Findings: Sentence Length, Silent git Failures, False Narrowing Three CodeRabbit findings on PR #958's round-1 review, all reproduced before the fix. ## New prose exceeded the 25-word sentence cap The docstring in `repo_gate.py`, `action.yml`'s new input description, and the new paragraph in `docs/reusable-workflows.md` each carried a sentence over the ASD-STE100 cap GOVERNANCE.md documents. Split each into shorter sentences; word counts verified with a scratch script before applying. ## `tracked()` silently discarded a genuine `git ls-files` failure `sh()` captures stdout only, so a failing command (a caller's malformed `--exclude` pathspec, an unreadable root) produced the same empty result as a legitimately empty scan, with the actual error dropped. `tracked()` now runs the command directly, prints git's own stderr on a nonzero exit, and `main()`'s "no tracked files" message now distinguishes an exclude-driven empty result from the original "not a git repo" case. ## The narrowing note fired even when nothing was excluded `--exclude no/such/path/**` (a caller's own typo) reported "narrowed the tracked-file scan" despite matching zero tracked files, hiding exactly the kind of caller mistake the note exists to surface. `main()` now compares the filtered and unfiltered scans and only claims narrowing when the count actually dropped, reporting the file count removed. ## Verified Reproduced all three fault shapes against a scratch repo and against this checkout directly before fixing: a >25-word new sentence would have failed `prose_lint.py --diff origin/develop`, a mocked git failure was silently swallowed, and `--exclude no/such/path/**` printed a false "narrowed" note. Added `test_a_failed_ls_files_call_prints_gits_own_error` and `test_a_pattern_matching_nothing_is_not_reported_as_narrowing`; updated the existing wiring test for the new note wording. Full test suite (806 tests), ruff check and format, mypy, `repo_gate.py` against this checkout (including both new note branches exercised directly), and `prose_lint.py --diff origin/develop` all pass clean. --- .github/actions/repo-gate/action.yml | 6 ++-- .github/actions/repo-gate/repo_gate.py | 46 ++++++++++++++++++-------- docs/reusable-workflows.md | 4 +-- scripts/tests/test_repo_gate.py | 26 +++++++++++++-- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/.github/actions/repo-gate/action.yml b/.github/actions/repo-gate/action.yml index 0caf423c..0e9da65b 100644 --- a/.github/actions/repo-gate/action.yml +++ b/.github/actions/repo-gate/action.yml @@ -4,9 +4,9 @@ description: Check action pins and line-ending contracts. inputs: exclude-globs: description: >- - Newline-separated git pathspec patterns (for example 'themes/PaperMod/**'), each turned - into a `--exclude` argument for repo_gate.py so a caller vendoring a subtree it does not - author can scope every check's tracked-file scan out of it. Empty by default: nothing is + Newline-separated git pathspec patterns, for example 'themes/PaperMod/**'. Each becomes a + `--exclude` argument for repo_gate.py. A caller vendoring a subtree it does not author can + scope every check's tracked-file scan out of it this way. Empty by default: nothing is excluded, and every check keeps scanning the whole repository. required: false default: '' diff --git a/.github/actions/repo-gate/repo_gate.py b/.github/actions/repo-gate/repo_gate.py index 28725d6f..46508234 100755 --- a/.github/actions/repo-gate/repo_gate.py +++ b/.github/actions/repo-gate/repo_gate.py @@ -118,16 +118,20 @@ def pin_resolves(nwo: str, sha: str, cache: dict[tuple[str, str], bool | None]) def tracked(root: Path, exclude: list[str] | None = None) -> list[str]: """Every git-tracked path, narrowed by `exclude` pathspec patterns where the caller gives any. - Each pattern is turned into a `:!` exclude pathspec and appended after `--`, so a - caller vendoring a subtree it does not author (per GOVERNANCE.md's carry-versus-reach test) - can scope every check that reads this list out of that subtree without touching the checks - themselves. Additive only: an empty or absent `exclude` scans exactly what it always has. + Each pattern becomes a `:!` exclude pathspec, appended to `git ls-files` after `--`. + A caller vendoring a subtree it does not author, per GOVERNANCE.md's carry-versus-reach test, + can scope every check out of that subtree this way. No check itself needs to change. + Additive only: an empty or absent `exclude` scans exactly what it always has. """ args = ["git", "-C", str(root), "ls-files"] if exclude: args += ["--", *(f":!{pattern}" for pattern in exclude)] - out = sh(*args) - return [l for l in out.split("\n") if l] + result = subprocess.run(args, capture_output=True, text=True, check=False) + # A failed command and a valid empty result would otherwise look the same to the caller. + # The command's own stderr (a bad pathspec, an unreadable root) is surfaced rather than dropped. + if result.returncode != 0 and result.stderr.strip(): + print(f"git ls-files failed: {result.stderr.strip()}", file=sys.stderr) + return [l for l in result.stdout.split("\n") if l] def workflow_files(files: list[str]) -> list[str]: @@ -363,15 +367,31 @@ def main(argv: list[str] | None = None) -> int: root = Path(a.root).resolve() files = tracked(root, a.exclude) if not files: - print(f"{root}: not a git repo or no tracked files", file=sys.stderr) + # `tracked()` already printed git's own stderr above where the command itself failed. + # This distinguishes that root cause from a caller's exclude matching every tracked file. + if a.exclude: + print( + f"{root}: no tracked files remain after excluding " + f"{', '.join(a.exclude)} (or the ls-files command above failed)", + file=sys.stderr, + ) + else: + print(f"{root}: not a git repo or no tracked files", file=sys.stderr) return 2 if a.exclude: - # Printed once, ahead of every check, since the narrowing applies to the whole scan rather than to one check. - # The docstring's rule is that a check saying it did less than its name prints that, rather than leaving it to be inferred from a clean line. - print( - f"note: {len(a.exclude)} exclude pattern(s) narrowed the tracked-file scan: " - f"{', '.join(a.exclude)}" - ) + # Compared against the unfiltered scan. + # A pattern matching nothing tracked is not reported as narrowing, which would misreport a caller's own typo as having worked. + excluded = len(tracked(root)) - len(files) + if excluded > 0: + print( + f"note: {len(a.exclude)} exclude pattern(s) narrowed the tracked-file scan " + f"by {excluded} file(s): {', '.join(a.exclude)}" + ) + else: + print( + f"note: {len(a.exclude)} exclude pattern(s) matched no tracked file, so " + f"nothing was narrowed: {', '.join(a.exclude)}" + ) total = 0 for name in a.check or sorted(CHECKS): diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index d34cb3f7..9cbed25d 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -365,7 +365,7 @@ A repo that vendors a theme or imports content it does not author narrows the Li `validate-task.yml` appends each line after `**/*.md` in the Lint Markdown step's own `globs:` block, unvalidated. A negated glob excludes, the intended use, but a non-negated one adds to what is linted rather than narrowing it. -The repo gate's `sha-pin` and `eol-coverage` checks read the same tracked-file list and hit the same wall for a vendored subtree carrying its own CI: PaperMod's own workflows pin actions by floating tag, which Blog does not author and, per `themes/README.md`'s byte-identical-to-upstream invariant, does not locally edit either. `repo-gate-exclude-globs` narrows that scan the same way, one git pathspec pattern per line: +The repo gate's `sha-pin` and `eol-coverage` checks read the same tracked-file list, hitting the same wall for a vendored subtree carrying its own CI. PaperMod's own workflows pin actions by floating tag, which Blog does not author. Per `themes/README.md`'s byte-identical-to-upstream invariant, Blog does not locally edit them either. `repo-gate-exclude-globs` narrows that scan the same way, one git pathspec pattern per line: ```yaml with: @@ -373,7 +373,7 @@ The repo gate's `sha-pin` and `eol-coverage` checks read the same tracked-file l themes/PaperMod/** ``` -`validate-task.yml` passes each line straight through to the `repo-gate` composite action's own `exclude-globs` input, which turns it into a `--exclude` argument for `repo_gate.py`. Unlike `markdown-exclude-globs`, a line here is never negated: it is always a pathspec to drop from `git ls-files`, so `themes/PaperMod/**` excludes rather than `!themes/PaperMod/**`. +`validate-task.yml` passes each line straight through to the `repo-gate` composite action's own `exclude-globs` input. That input turns each line into a `--exclude` argument for `repo_gate.py`. Unlike `markdown-exclude-globs`, a line here is never negated: it is always a pathspec to drop from `git ls-files`. So `themes/PaperMod/**` excludes, rather than `!themes/PaperMod/**`. ## Adopting the Pure Functions diff --git a/scripts/tests/test_repo_gate.py b/scripts/tests/test_repo_gate.py index 6be05c40..ec9649cb 100755 --- a/scripts/tests/test_repo_gate.py +++ b/scripts/tests/test_repo_gate.py @@ -586,15 +586,37 @@ def test_the_cli_wires_the_exclude_flag_through_to_tracked(self) -> None: ) self.assertEqual(0, code) self.assertIn( - "note: 1 exclude pattern(s) narrowed the tracked-file scan: vendor/**", + "note: 1 exclude pattern(s) narrowed the tracked-file scan by 2 file(s): vendor/**", out.getvalue(), ) - def test_the_narrowing_note_is_silent_when_nothing_was_excluded(self) -> None: + def test_the_narrowing_note_is_silent_when_no_exclude_was_given(self) -> None: with contextlib.redirect_stdout(io.StringIO()) as out: repo_gate.main(["--root", str(self.tmp), "--check", "sha-pin"]) self.assertNotIn("narrowed the tracked-file scan", out.getvalue()) + def test_a_pattern_matching_nothing_is_not_reported_as_narrowing(self) -> None: + """A caller's own typo drops zero files, and a false 'narrowed' note would hide that.""" + with contextlib.redirect_stdout(io.StringIO()) as out: + repo_gate.main( + ["--root", str(self.tmp), "--check", "sha-pin", "--exclude", "no/such/path/**"] + ) + printed = out.getvalue() + self.assertIn("matched no tracked file, so nothing was narrowed", printed) + self.assertNotIn("narrowed the tracked-file scan by", printed) + + def test_a_failed_ls_files_call_prints_gits_own_error(self) -> None: + """A command failure and a valid empty result both return `[]`; only this prints why.""" + proc = subprocess.CompletedProcess([], 128, "", "fatal: Invalid pathspec magic 'bogus'\n") + with ( + mock.patch.object(repo_gate.subprocess, "run", return_value=proc), + contextlib.redirect_stderr(io.StringIO()) as err, + ): + files = repo_gate.tracked(self.tmp, ["bogus/**"]) + self.assertEqual([], files) + self.assertIn("git ls-files failed", err.getvalue()) + self.assertIn("Invalid pathspec magic", err.getvalue()) + class TestHarness(unittest.TestCase): def test_this_module_collects_a_plausible_number_of_cases(self) -> None: