Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions polymath_code_standard/checkers/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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),
Expand All @@ -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)}'
),
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
82 changes: 82 additions & 0 deletions tests/test_general.py
Original file line number Diff line number Diff line change
@@ -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'<svg/>')

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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading