From 72d4c323fed94c579d6ace92d024961befe902ce Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Tue, 27 Oct 2026 21:07:17 +0100 Subject: [PATCH 1/2] Read untracked local path allowances for staged checks External and upstream repositories must not carry owner tooling in their PRs. --staged checks now additionally read reasoned path exceptions from $(git rev-parse --git-common-dir)/info/repo-hygiene-allow, outside all tracked content. The file grants path exceptions only: identities are rejected, range and CI checks never read it, and gitleaks still runs on every allowed path. Failure guidance now names the local file so users of external repositories do not commit owner tooling to fix a local artifact. --- REPO_HYGIENE.md | 8 ++++++ scripts/repo_hygiene.py | 52 +++++++++++++++++++++++++++++++------- tests/test_repo_hygiene.py | 51 +++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/REPO_HYGIENE.md b/REPO_HYGIENE.md index 7deb4e6..95f1f6d 100644 --- a/REPO_HYGIENE.md +++ b/REPO_HYGIENE.md @@ -54,5 +54,13 @@ Only synthetic or published data belong in fixtures. Never commit non-public machine/experiment or collaborator data (ITER/TC24/IMAS, AUG shots, JT-60SA). Keep campaign outputs outside Git, with checksums and a recorded location. +External and upstream repositories never commit this tooling. For local +staged checks only, reasoned path exceptions may instead live in the untracked +file `$(git rev-parse --git-common-dir)/info/repo-hygiene-allow`, shared by +all worktrees of the repository and never part of any PR. It uses the same +strict `globreason` path syntax; an `identities:` section, absolute +patterns or missing reasons are rejected. Range and CI checks never read it, +and it never waives secret detection. + Heavy CI runs only in public repositories. Private repositories validate via cluster jobs recorded in the PR; GitHub Free cannot enforce private rulesets. diff --git a/scripts/repo_hygiene.py b/scripts/repo_hygiene.py index 038f994..9be2890 100755 --- a/scripts/repo_hygiene.py +++ b/scripts/repo_hygiene.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 -"""Staged / commit-range gate. Exceptions live in .repo-hygiene-allow.""" +"""Staged / commit-range gate. Exceptions live in .repo-hygiene-allow. + +Only --staged local checks additionally read untracked path exceptions from +$(git rev-parse --git-common-dir)/info/repo-hygiene-allow so external +repositories can keep local artifacts without committing owner tooling. +""" import argparse import fnmatch import json @@ -21,11 +26,7 @@ def git(*args, **kwargs): return subprocess.check_output(['git', *args], **kwargs) -def allowances(ref): - name = f'{ref}:.repo-hygiene-allow' if ref else ':.repo-hygiene-allow' - if subprocess.run(['git', 'cat-file', '-e', name], stderr=subprocess.DEVNULL).returncode: - return [], set() - text = git('show', name).decode('utf-8') +def parse_allow(text, source, identities_allowed=True): rules, identities = [], set() section = "paths" for number, line in enumerate(text.splitlines(), 1): @@ -33,22 +34,49 @@ def allowances(ref): continue if line.strip() in ('identities:', 'paths:'): section = line.strip()[:-1] + if section == 'identities' and not identities_allowed: + raise ValueError(f'{source}:{number}: local allowances cannot exempt ' + 'identities; only path exceptions are permitted') continue fields = line.split('\t', 1) if len(fields) != 2 or not all(field.strip() for field in fields): - raise ValueError(f'.repo-hygiene-allow:{number}: expected {section} entryreason') + raise ValueError(f'{source}:{number}: expected {section} entryreason') pattern, reason = (field.strip() for field in fields) if section == 'identities': if not re.fullmatch(r'[^\s<>@*?]+@[^\s<>@*?]+', pattern): - raise ValueError(f'.repo-hygiene-allow:{number}: expected exact emailreason') + raise ValueError(f'{source}:{number}: expected exact emailreason') identities.add(pattern.casefold()) continue if pattern.startswith('/') or '..' in pattern.split('/'): - raise ValueError(f'.repo-hygiene-allow:{number}: use repository-relative globs') + raise ValueError(f'{source}:{number}: use repository-relative globs') rules.append((pattern, reason)) return rules, identities +def allowances(ref): + name = f'{ref}:.repo-hygiene-allow' if ref else ':.repo-hygiene-allow' + if subprocess.run(['git', 'cat-file', '-e', name], stderr=subprocess.DEVNULL).returncode: + return [], set() + return parse_allow(git('show', name).decode('utf-8'), '.repo-hygiene-allow') + + +def local_allow_file(): + common = Path(git('rev-parse', '--git-common-dir').decode().strip()) + return (common if common.is_absolute() else Path.cwd() / common) / 'info' / 'repo-hygiene-allow' + + +def local_allowances(): + """Path exceptions outside tracked content, for --staged checks only. + + Never grants identity exemptions; secrets are never waived because + gitleaks runs after path allowances regardless of these rules.""" + path = local_allow_file() + if not path.exists(): + return [] + rules, _ = parse_allow(path.read_text('utf-8'), str(path), identities_allowed=False) + return rules + + def warn_local_identity(): """Offline and advisory: Git environment overrides can change either identity.""" configured = subprocess.run(['git', 'config', 'user.email'], capture_output=True, @@ -126,6 +154,8 @@ def check(staged, base, head, repository=None): blobs.extend(entries(git('diff', '--raw', '-z', '--no-abbrev', '--no-renames', '--diff-filter=AMT', base, head))) rules, allowed_identities = allowances(None if staged else head) + if staged: + rules = rules + local_allowances() oids = sorted({oid for _, oid in blobs}) sizes = {} if oids: @@ -149,6 +179,10 @@ def check(staged, base, head, repository=None): print('\n'.join(failures[:30]), file=sys.stderr) print('Archive results externally; justify synthetic/published fixtures in ' '.repo-hygiene-allow (globreason). No environment override.', file=sys.stderr) + print(f'In an external repository add the reasoned exception to the local file ' + f'{local_allow_file()} instead of committing owner tooling there; ' + 'local rules cover paths only and never waive secret detection.', + file=sys.stderr) return 1 # Force upstream defaults: repo config, ignore files, inline comments and env # must not silently bypass secret detection. Path allowances never waive secrets. diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 7411684..062ff15 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -93,12 +93,63 @@ def test_secret_staged_and_range(self): self.git('commit', '-qm', 'remove') self.assertEqual(self.gate('--base', self.base).returncode, 1) + def local_allow(self): + common = Path(self.git('rev-parse', '--git-common-dir').strip()) + if not common.is_absolute(): + common = self.repo / common + info = common / 'info' + info.mkdir(parents=True, exist_ok=True) + return info / 'repo-hygiene-allow' + + def test_staged_local_allowance(self): + self.add('runs/output.npz') + self.assertEqual(self.gate().returncode, 1) # blocked without local rule + allow = self.local_allow() + allow.write_text('runs/**\tLocal scratch artifact from run.py\n') + self.assertEqual(self.gate().returncode, 0) # passes with reasoned local rule + self.assertNotIn('repo-hygiene-allow', self.git('status', '--porcelain')) + allow.write_text('runs/**\n') # missing reason + self.assertEqual(self.gate().returncode, 2) # malformed fails + allow.unlink() + self.assertEqual(self.gate().returncode, 1) # missing file: blocked again + + def test_local_allowance_never_waives_secrets(self): + token = 'gh' + 'p_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8' + self.add('runs/notes.txt', ('token=' + token + '\n').encode()) + self.local_allow().write_text('runs/**\tLocal scratch output\n') + result = self.gate() + self.assertEqual(result.returncode, 1) # gitleaks still runs and blocks + + def test_local_allowance_rejects_identities_and_absolute_paths(self): + self.add('runs/output.npz') + self.local_allow().write_text('identities:\nbot@example.org\tSome bot\n') + self.assertEqual(self.gate().returncode, 2) + self.local_allow().write_text('/runs/**\tAbsolute pattern\n') + self.assertEqual(self.gate().returncode, 2) + + def test_range_ignores_local_allowance(self): + self.local_allow().write_text('trace.npy\tLocal scratch artifact\n') + self.add('trace.npy') + self.git('commit', '-qm', 'add output') + self.assertEqual(self.gate('--base', self.base).returncode, 1) + + def test_tracked_allowance_applies_to_range(self): + (self.repo / '.repo-hygiene-allow').write_text( + 'trace.npy\tSynthetic fixture from generator.py\n') + self.git('add', '.repo-hygiene-allow') + self.git('commit', '-qm', 'allow fixture') + self.add('trace.npy') + self.git('commit', '-qm', 'add fixture') + self.assertEqual(self.gate('--base', self.base).returncode, 0) + def test_linked_worktree(self): worktree = self.repo.parent / 'worktree' self.git('worktree', 'add', '-qb', 'worktree-test', str(worktree)) self.repo = worktree self.add('results/archive.csv.gz') self.assertEqual(self.gate().returncode, 1) + self.local_allow().write_text('results/**\tLocal scratch output\n') + self.assertEqual(self.gate().returncode, 0) if __name__ == '__main__': From b84738ca5e7e170efba384de1e72ce9fa9f66cdd Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Tue, 27 Oct 2026 21:13:15 +0100 Subject: [PATCH 2/2] Restrict local allowance guidance to staged checks --- REPO_HYGIENE.md | 15 +++++++-------- scripts/repo_hygiene.py | 9 +++++---- tests/test_repo_hygiene.py | 9 +++++++-- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/REPO_HYGIENE.md b/REPO_HYGIENE.md index 95f1f6d..1b53294 100644 --- a/REPO_HYGIENE.md +++ b/REPO_HYGIENE.md @@ -45,7 +45,7 @@ to at most 5 MiB per PR (or staged change); repeated copies of the same blob count once. Modified files count at their full new size. Historic, unchanged files are not charged. A rename is inspected as an addition. -Exceptions come only from the committed head (or staged index) file +Shared exceptions come from the committed head (or staged index) file `.repo-hygiene-allow`: repository-relative glob, TAB, one-line reason. Blank lines and `#` comments are accepted. Review allowances as part of the PR. For example, `tests/fixtures/**` followed by a TAB and @@ -54,13 +54,12 @@ Only synthetic or published data belong in fixtures. Never commit non-public machine/experiment or collaborator data (ITER/TC24/IMAS, AUG shots, JT-60SA). Keep campaign outputs outside Git, with checksums and a recorded location. -External and upstream repositories never commit this tooling. For local -staged checks only, reasoned path exceptions may instead live in the untracked -file `$(git rev-parse --git-common-dir)/info/repo-hygiene-allow`, shared by -all worktrees of the repository and never part of any PR. It uses the same -strict `globreason` path syntax; an `identities:` section, absolute -patterns or missing reasons are rejected. Range and CI checks never read it, -and it never waives secret detection. +For staged checks in external repositories, keep local path exceptions in +`$(git rev-parse --git-common-dir)/info/repo-hygiene-allow`. This file is shared +by the repository’s worktrees and stays outside commits. Use `globreason`, +as in `.repo-hygiene-allow`; absolute patterns, missing reasons and `identities:` +sections are rejected. Range and CI checks ignore this file, and its rules never +waive secret detection. Heavy CI runs only in public repositories. Private repositories validate via cluster jobs recorded in the PR; GitHub Free cannot enforce private rulesets. diff --git a/scripts/repo_hygiene.py b/scripts/repo_hygiene.py index 9be2890..a04bbb2 100755 --- a/scripts/repo_hygiene.py +++ b/scripts/repo_hygiene.py @@ -179,10 +179,11 @@ def check(staged, base, head, repository=None): print('\n'.join(failures[:30]), file=sys.stderr) print('Archive results externally; justify synthetic/published fixtures in ' '.repo-hygiene-allow (globreason). No environment override.', file=sys.stderr) - print(f'In an external repository add the reasoned exception to the local file ' - f'{local_allow_file()} instead of committing owner tooling there; ' - 'local rules cover paths only and never waive secret detection.', - file=sys.stderr) + if staged: + print(f'In an external repository add the reasoned exception to the local file ' + f'{local_allow_file()} instead of committing owner tooling there; ' + 'local rules cover paths only and never waive secret detection.', + file=sys.stderr) return 1 # Force upstream defaults: repo config, ignore files, inline comments and env # must not silently bypass secret detection. Path allowances never waive secrets. diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 062ff15..50d013e 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -103,7 +103,9 @@ def local_allow(self): def test_staged_local_allowance(self): self.add('runs/output.npz') - self.assertEqual(self.gate().returncode, 1) # blocked without local rule + result = self.gate() + self.assertEqual(result.returncode, 1) + self.assertIn(str(self.local_allow()), result.stderr) allow = self.local_allow() allow.write_text('runs/**\tLocal scratch artifact from run.py\n') self.assertEqual(self.gate().returncode, 0) # passes with reasoned local rule @@ -131,7 +133,10 @@ def test_range_ignores_local_allowance(self): self.local_allow().write_text('trace.npy\tLocal scratch artifact\n') self.add('trace.npy') self.git('commit', '-qm', 'add output') - self.assertEqual(self.gate('--base', self.base).returncode, 1) + result = self.gate('--base', self.base) + self.assertEqual(result.returncode, 1) + self.assertIn('.repo-hygiene-allow (globreason)', result.stderr) + self.assertNotIn(str(self.local_allow()), result.stderr) def test_tracked_allowance_applies_to_range(self): (self.repo / '.repo-hygiene-allow').write_text(