From 60a6e14528586b1b93e729209f294e0142c28d6b Mon Sep 17 00:00:00 2001 From: Emerson Knapp Date: Fri, 6 Nov 2026 16:31:48 -0800 Subject: [PATCH 1/2] feat: require Git LFS files to be staged as pointers Signed-off-by: Emerson Knapp --- README.md | 1 + polymath_code_standard/checkers/general.py | 46 ++++++++++++ tests/test_general.py | 82 ++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/test_general.py diff --git a/README.md b/README.md index 217fea6..9627ac7 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Applies file hygiene checks to all staged files. - Ensures shebanged scripts are executable - Validates symlinks - Blocks git submodules +- Requires files that `.gitattributes` stores in Git LFS to be staged as LFS pointers, which catches commits made without git-lfs installed - Adds a trailing newline to files - Normalizes line endings - Removes trailing whitespace diff --git a/polymath_code_standard/checkers/general.py b/polymath_code_standard/checkers/general.py index 52d3d80..800f071 100644 --- a/polymath_code_standard/checkers/general.py +++ b/polymath_code_standard/checkers/general.py @@ -5,6 +5,8 @@ from polymath_code_standard.checker import CheckerGroup, Result, check_group, filter_files +LFS_POINTER_PREFIX = b'version https://git-lfs' + @check_group class GeneralGroup(CheckerGroup): @@ -20,6 +22,7 @@ def run(self, args: argparse.Namespace) -> list[Result]: self._check('check-shebang-scripts-are-executable', [], text), self._check('check-symlinks', [], symlinks), self.run_forbid_submodules(), + self.run_lfs_pointers(args.files), self._check('end-of-file-fixer', [], text), self._check('mixed-line-ending', [], text), self._check('trailing-whitespace-fixer', [], text), @@ -40,3 +43,46 @@ def run_forbid_submodules() -> Result: cmd=cmd, ) return Result(name='forbid-submodules', passed=True, cmd=cmd) + + @staticmethod + def run_lfs_pointers(files: list[str]) -> Result: + """Require staged files that .gitattributes assigns `filter=lfs` to be staged as Git LFS pointers. + + Reads the index through git, so it works without git-lfs installed. + Files that are not staged are skipped. + """ + name = 'lfs-pointers' + if not files: + return Result(name=name, passed=True, skipped=True) + attrs = subprocess.run( + ['git', 'check-attr', '-z', '--stdin', 'filter'], + input=''.join(f'{f}\0' for f in files), + capture_output=True, + text=True, + ) + if attrs.returncode != 0: + return Result(name=name, passed=False, output=attrs.stderr.strip()) + # Each record is path, attribute and value. + records = attrs.stdout.split('\0') + lfs = [path for path, value in zip(records[0::3], records[2::3]) if value == 'lfs'] + if not lfs: + return Result(name=name, passed=True) + staged = subprocess.run(['git', 'ls-files', '--stage', '-z', '--', *lfs], capture_output=True, text=True) + plain = [] + for entry in filter(None, staged.stdout.split('\0')): + info, path = entry.split('\t', 1) + blob = subprocess.run(['git', 'cat-file', 'blob', info.split()[1]], capture_output=True).stdout + if not blob.startswith(LFS_POINTER_PREFIX): + plain.append(path) + if not plain: + return Result(name=name, passed=True) + listing = '\n'.join(f' {p}' for p in plain) + return Result( + name=name, + passed=False, + output=( + f'These files belong in Git LFS, per .gitattributes, but are staged as plain files:\n{listing}\n' + 'Install git-lfs, enable it with `git lfs install`, and restage them:\n' + f' git add --renormalize {" ".join(plain)}' + ), + ) diff --git a/tests/test_general.py b/tests/test_general.py new file mode 100644 index 0000000..819333c --- /dev/null +++ b/tests/test_general.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the Git LFS pointer check in the general group.""" + +import subprocess + +import pytest + +from polymath_code_standard.checkers.general import GeneralGroup + +POINTER = b'version https://git-lfs.github.com/spec/v1\noid sha256:0\nsize 1\n' + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + # Without the user's LFS filter, `git add` stages files as they are, as on a machine without git-lfs. + monkeypatch.setenv('GIT_CONFIG_GLOBAL', '/dev/null') + monkeypatch.setenv('GIT_CONFIG_NOSYSTEM', '1') + subprocess.run(['git', 'init', '-q'], cwd=tmp_path, check=True) + monkeypatch.chdir(tmp_path) + (tmp_path / '.gitattributes').write_text('*.webp filter=lfs diff=lfs merge=lfs -text\n') + (tmp_path / 'img').mkdir() + return tmp_path + + +def stage(repo, path, content): + (repo / path).write_bytes(content) + subprocess.run(['git', 'add', path], cwd=repo, check=True) + + +def test_plain_lfs_file_fails(repo): + stage(repo, 'img/a.webp', b'RIFF....WEBP') + + result = GeneralGroup.run_lfs_pointers(['img/a.webp']) + + assert not result.passed + assert ' img/a.webp\n' in result.output + assert 'git add --renormalize img/a.webp' in result.output + + +def test_pointer_passes(repo): + stage(repo, 'img/a.webp', POINTER) + + assert GeneralGroup.run_lfs_pointers(['img/a.webp']).passed + + +def test_file_outside_lfs_passes(repo): + stage(repo, 'img/a.svg', b'') + + assert GeneralGroup.run_lfs_pointers(['img/a.svg']).passed + + +def test_unstaged_file_skipped(repo): + (repo / 'img' / 'a.webp').write_bytes(b'RIFF....WEBP') + + assert GeneralGroup.run_lfs_pointers(['img/a.webp']).passed + + +def test_checks_staged_content_not_working_tree(repo): + stage(repo, 'img/a.webp', b'RIFF....WEBP') + (repo / 'img' / 'a.webp').write_bytes(POINTER) + + assert not GeneralGroup.run_lfs_pointers(['img/a.webp']).passed + + +def test_absolute_path(repo): + stage(repo, 'img/a.webp', b'RIFF....WEBP') + + assert not GeneralGroup.run_lfs_pointers([str(repo / 'img' / 'a.webp')]).passed + + +def test_reports_only_plain_files(repo): + stage(repo, 'img/a.webp', POINTER) + stage(repo, 'img/b.webp', b'RIFF....WEBP') + + result = GeneralGroup.run_lfs_pointers(['img/a.webp', 'img/b.webp', 'img/c.webp']) + + assert result.output.endswith('git add --renormalize img/b.webp') + + +def test_no_files_skipped(): + assert GeneralGroup.run_lfs_pointers([]).skipped From 792d1468a8d0fd85a9c39527c89a6baf84579c69 Mon Sep 17 00:00:00 2001 From: Emerson Knapp Date: Fri, 6 Nov 2026 16:43:50 -0800 Subject: [PATCH 2/2] release: 2.7.0 Signed-off-by: Emerson Knapp --- CHANGELOG.md | 15 +++++++++++++++ README.md | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5dbfd..fb27f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to this project are documented in this file. Releases follow semantic versioning as described in [DEVELOPING.md](./DEVELOPING.md). +## 2.7.0 + +Minor bump: a new check in `polymath-general`. + +### Added + +- `polymath-general` requires files that `.gitattributes` assigns to Git LFS to be staged as LFS pointers. + It catches files committed from a machine without git-lfs installed, and prints the `git add --renormalize` command that restages them. + A repository that already has such files committed fails the check under `--all-files` until they are restaged. + +### Fixed + +- `polymath-go` works with Go 1.27. + The pinned golangci-lint is now 2.15.0, since 2.13.2 cannot read the Go 1.27 standard library. + ## 2.6.1 ### Fixed diff --git a/README.md b/README.md index 9627ac7..01a5afd 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Use only the hooks that apply to your project. --- repos: - repo: https://github.com/polymathrobotics/polymath_code_standard - rev: v2.6.1 + rev: v2.7.0 hooks: # File hygiene for all staged files - id: polymath-general diff --git a/pyproject.toml b/pyproject.toml index b40fcd2..9e175ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polymath-code-standard" -version = "2.6.1" +version = "2.7.0" description = "Polymath Code Standard pre-commit hooks" requires-python = ">=3.10" dependencies = [ diff --git a/uv.lock b/uv.lock index 00d5b18..d6244ed 100644 --- a/uv.lock +++ b/uv.lock @@ -829,7 +829,7 @@ wheels = [ [[package]] name = "polymath-code-standard" -version = "2.6.1" +version = "2.7.0" source = { editable = "." } dependencies = [ { name = "ansible-lint" },