signal-bot/.pre-commit-config.yaml
Krzysztof kuhy Rudnicki 9d546fef9a feat: import a folder of photos without importing it twice
tool/tagger.py did not run at all: 48957a1 renamed pack.default_dir to
default_dirs and missed the caller, so the tagger raised ImportError at
module scope through two releases. Nothing could have caught it -- tool/ is
outside --cov and mypy ran with [signal_bot, fake_signal]. Both are widened
here, and tests/test_tool_imports.py imports every script under tool/.

--import was a filename check and shutil.copy2. Pointed at a phone export
that overlaps the album it re-imported the same photo under whatever name
the exporter used that time. It now identifies files by magic bytes, strips
them through the same media_strip path !add_photo uses, and dedups on exact
bytes twice: the source hash, then -- only for what survives -- the hash
after stripping. sources.json records both, so a second run on the same
folder is a no-op. What the album refuses is moved to _skipped/<reason>/
beside the source, never deleted.

Two things that made "strip on import" impossible until now:

media_tags claims -all= zeroes the QuickTime date tags and allows them by
value. It does not; measured, it leaves CreateDate at its real value, and
every mp4 was being refused. -time:all= is what zeroes them.

And a tag allowlist cannot see the worst class at all. IMG_6924.mp4 carries
mebx timed-metadata holding per-frame face bounding boxes and a face id,
and those survive a complete tag strip because they live in mdat as opaque
samples. media_tracks.py drops the track with ffmpeg and verifies with
ffprobe that only video and audio survived. This is not the ffmpeg mistake
media_strip warns about: that was ffmpeg replacing exiftool for tags, this
is track removal with exiftool still doing every tag, and the gate is a
positive read-back rather than an exit code.

The roster was write-once. The setup form only appeared on an empty album,
Album.setup overwrote the file whole, and keys were positional (p{len+1}),
so inserting somebody renumbered everyone after them and repointed every
tags.json entry at the wrong person. Keys now come from the highest number
in use, the form merges, and a person can be added mid-session.

--untagged filters the page and never the server's list. /media/{index} and
/api/tag both index the full album, so a shorter list server-side would
show item i of the album while tagging item i of the filtered set.

Measured on the live album: 174 source files -> 1 not media, 82 already in
pack, 24 duplicate in batch, 67 imported, and re-running imports nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
2026-08-31 13:22:57 +02:00

149 lines
5.3 KiB
YAML

# Pre-commit hooks for signal-bot.
#
# Install with: pre-commit install
# Run manually: pre-commit run --all-files
#
# Every hook is `language: system` because the tools already live in .venv;
# pre-commit must not build isolated environments for them. fail_fast so a
# later check cannot run after an earlier one has already rejected the commit.
fail_fast: true
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-merge-conflict
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable
- id: detect-private-key
- id: debug-statements
- id: check-ast
- id: mixed-line-ending
args: [--fix=lf]
- repo: local
hooks:
# Suppressions are blocked mechanically, not by discipline. This repo
# has zero of them and the only way it stays that way is a gate: the
# rule is "fix the finding, or ask first", and a habit is not a gate.
- id: no-noqa
name: block noqa / type:ignore comments
entry: '(?i)#\s*(noqa|type:\s*ignore)'
language: pygrep
types: [python]
- id: no-ruff-noqa
name: block file-level ruff noqa
entry: '(?i)#\s*ruff:\s*noqa'
language: pygrep
types: [python]
# This repository is public and holds a private message database. The
# database is gitignored, but three binary SQLite files reached the
# history anyway before this gate existed, so .gitignore is evidently
# not the whole defence. Exceptions go in .binary-allowlist.
# This repository is public and leaked once: real names, two phone
# numbers and a member's Signal uuid reached GitHub and only came off
# it by deleting the repository. The checker learns what to refuse from
# OUTSIDE the repo -- the photo pack, the message database, .env -- so
# the list of real names is never in the thing being checked.
- id: personal-data
name: no personal data
entry: scripts/check_personal_data.sh
language: system
types: [text]
# The same check on the commit message, because that is where two of
# the leaks actually lived.
- id: personal-data-message
name: no personal data in the commit message
entry: scripts/check_personal_data.sh --message
language: system
stages: [commit-msg]
always_run: true
pass_filenames: true
- id: no-binaries
name: no binary or image files
entry: scripts/check_no_binaries.sh
language: script
pass_filenames: true
# The shared 250-line cap. Checks only the files in the commit, so an
# unrelated commit never breaks. No baseline, no allowlist -- those are
# suppressions. The exemption list lives with the checker in ~/utils so
# this repo's idea of the rule cannot drift from every other repo's.
- id: file-length
name: file length <= 250 lines
entry: scripts/check_file_length.sh
language: system
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
- id: md-naming
name: markdown naming convention
entry: scripts/check_md_naming.sh
language: system
files: \.md$
# Exact pins at newest stable. Runs repo-wide: a pin goes stale because
# upstream released, not because this commit touched the manifest.
- id: dependency-freshness
name: dependencies are newest stable
entry: scripts/check_dependency_freshness.sh --all
language: system
pass_filenames: false
always_run: true
- id: ruff-format
name: ruff format
entry: .venv/bin/ruff format --check
language: system
types_or: [python, pyi]
- id: ruff
name: ruff (ALL rules)
entry: .venv/bin/ruff check --force-exclude
language: system
types_or: [python, pyi]
- id: mypy
name: mypy --strict
entry: .venv/bin/mypy
language: system
pass_filenames: false
args: [signal_bot, fake_signal, tool]
- id: shellcheck
name: shellcheck
# Same severity floor as CI: a stricter runner version must not be
# the thing that discovers a finding this hook let through.
entry: shellcheck -x --severity=style
language: system
types: [shell]
# The watchdog is bash, so pytest cannot reach it. Its own suite stubs
# curl and docker and drives every branch, including the give-up cap.
- id: watchdog-tests
name: watchdog behaviour (bash)
entry: scripts/test_watchdog.sh
language: system
pass_filenames: false
files: ^scripts/(signal_api_watchdog|test_watchdog)\.sh$
# 100% branch coverage, no omits. Runs on every commit: coverage is the
# whole point of the suite, and a partial run cannot prove it.
- id: pytest
name: pytest (100% branch coverage)
entry: .venv/bin/python -m pytest -q
language: system
pass_filenames: false
always_run: true