From 40192b2cb522a096b4d6fb462586c0442c2d33a8 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leroy Date: Fri, 18 Sep 2026 20:43:03 -0400 Subject: [PATCH 1/2] chore: pre-commit hook to reject oversized files, ignore Testing/ Add a pre-commit hook (dev/git-hooks/pre-commit) that rejects a commit staging any file over 256 KiB, sized from the index so it works with partial stages and with `git commit `. It is not installed automatically; dev/install-git-hooks symlinks it (and anything else under dev/git-hooks/) into the hooks directory git actually runs - .git/hooks in a plain clone, or the common git dir for a linked worktree, so one run covers every worktree of a clone. Bypass with `git commit --no-verify` when a large file is genuinely project content. Also ignore Testing/, which `ctest` writes at the repo root (Testing/Temporary/{LastTest.log,CTestCostData.txt}) whenever it's run from there rather than from build/. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 5 +++++ dev/git-hooks/pre-commit | 38 ++++++++++++++++++++++++++++++++++++++ dev/install-git-hooks | 23 +++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100755 dev/git-hooks/pre-commit create mode 100755 dev/install-git-hooks diff --git a/.gitignore b/.gitignore index 975232e1..781181d1 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,8 @@ flat/ # untracked-but-addable - `git add test` would commit tens of thousands of # lines of objects. bin/ + +# CTest output. `ctest` writes Testing/Temporary/{LastTest.log,CTestCostData.txt} +# relative to the directory it runs in, so this turns up at the root whenever +# ctest is run from there rather than from build/. +Testing/ diff --git a/dev/git-hooks/pre-commit b/dev/git-hooks/pre-commit new file mode 100755 index 00000000..55cb6ea0 --- /dev/null +++ b/dev/git-hooks/pre-commit @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright (c) 2017-2026 Jean-Louis Leroy +# Distributed under the Boost Software License, Version 1.0. +# See accompanying file LICENSE_1_0.txt +# or copy at http://www.boost.org/LICENSE_1_0.txt) + +# Reject a commit that stages an oversized file. Bypass with `git commit +# --no-verify` when a large file really is project content. +# +# Not installed automatically - run dev/install-git-hooks once per clone (or +# per worktree that doesn't share this one's .git/hooks; linked worktrees do). +# +# Sizes come from the index (`git cat-file -s :path`), not the working tree, so +# this is right for `git add -p`, for `git commit ` (git hands the hook +# a temporary index), and on a repo with no commits yet. --diff-filter=ACMR +# skips deletions, which have no index entry to size. +# +# bash, not sh: `read -d` is a bashism, and dash is /bin/sh here. The redirect +# from a process substitution keeps the loop out of a subshell, so `status` +# survives it and every offender gets reported, not just the first. + +limit=$((256 * 1024)) # 256 KiB +status=0 + +while IFS= read -r -d '' f; do + size=$(git cat-file -s ":$f") || continue + if [ "$size" -gt "$limit" ]; then + printf 'pre-commit: %s is %s bytes, over the %s byte limit.\n' \ + "$f" "$size" "$limit" >&2 + status=1 + fi +done < <(git diff --cached --name-only --diff-filter=ACMR -z) + +if [ "$status" -ne 0 ]; then + echo "pre-commit: unstage it, or commit with --no-verify." >&2 +fi + +exit "$status" diff --git a/dev/install-git-hooks b/dev/install-git-hooks new file mode 100755 index 00000000..587fddf0 --- /dev/null +++ b/dev/install-git-hooks @@ -0,0 +1,23 @@ +#!/bin/bash +# Copyright (c) 2017-2026 Jean-Louis Leroy +# Distributed under the Boost Software License, Version 1.0. +# See accompanying file LICENSE_1_0.txt +# or copy at http://www.boost.org/LICENSE_1_0.txt) + +# Symlink dev/git-hooks/* into the hooks directory git will actually run - +# .git/hooks in a plain clone, or the common git dir for a linked worktree, so +# one run covers every worktree of this clone. +# +# Usage: dev/install-git-hooks + +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +hooks_src="$root_dir/dev/git-hooks" +hooks_dst="$(cd "$root_dir" && git rev-parse --git-path hooks)" + +for hook in "$hooks_src"/*; do + name="$(basename "$hook")" + ln -sf "$hook" "$hooks_dst/$name" + echo "installed $hooks_dst/$name -> $hook" +done From 09d74d48595e28514824440e2fbb15e1266a30b0 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leroy Date: Fri, 18 Sep 2026 20:49:36 -0400 Subject: [PATCH 2/2] ci: enforce the file-size limit server-side, on every blob a PR adds The local pre-commit hook is opt-in - nothing forces a contributor to run dev/install-git-hooks, and it's `--no-verify`-bypassable regardless. Add a "File size" CI job that re-checks the same 256 KiB limit server-side, over every blob any commit in the pull request introduces (not just the files that differ in the final base...head diff), so an oversized file can't reach develop even if the local hook never ran. A commit that adds a large file and a later commit in the same PR that shrinks or removes it again is still a permanent addition to history once merged - checking only the net diff would miss it, and checking every commit's blobs catches it. Extract the size check itself into dev/check-blob-size, shared by the hook and the CI job so the limit and the message live in one place. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ dev/check-blob-size | 26 ++++++++++++++++++++++++++ dev/git-hooks/pre-commit | 29 ++++++++++++++++------------- 3 files changed, 77 insertions(+), 13 deletions(-) create mode 100755 dev/check-blob-size diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 011e8366..c0ec928a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,41 @@ jobs: cmake --build ../build --target tests -j $(nproc) ctest --test-dir ../build -j $(nproc) --output-on-failure + file-size: + name: File size + # Only meaningful for a pull request: github.event.pull_request is unset + # on a push, and a push's commits are only ever ones already checked here + # as part of the PR that introduced them. + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + steps: + - name: Clone Boost.OpenMethod + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Every blob any commit in the PR introduces, not just the files that + # differ in the final base...head diff - a commit that adds an + # oversized file and a later commit in the same PR that shrinks or + # removes it still leaves that blob in history forever once merged. + # Same limit and message as the local dev/git-hooks/pre-commit hook + # (dev/check-blob-size), so a contributor who skipped installing it + # still can't land an oversized file. + - name: Check for oversized blobs + run: | + status=0 + + git rev-list "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" | + while read -r c; do + git diff-tree -r --no-commit-id --diff-filter=ACMR "$c" + done | awk '{print $4, $NF}' | sort -u > /tmp/blobs.txt + + while read -r oid path; do + dev/check-blob-size "$oid" "$path" || status=1 + done < /tmp/blobs.txt + + exit "$status" + flat-headers: name: Flat headers runs-on: ubuntu-24.04 diff --git a/dev/check-blob-size b/dev/check-blob-size new file mode 100755 index 00000000..dffe2a4a --- /dev/null +++ b/dev/check-blob-size @@ -0,0 +1,26 @@ +#!/bin/bash +# Copyright (c) 2017-2026 Jean-Louis Leroy +# Distributed under the Boost Software License, Version 1.0. +# See accompanying file LICENSE_1_0.txt +# or copy at http://www.boost.org/LICENSE_1_0.txt) + +# Check a single blob's size against the project's oversized-file limit. +# Shared by dev/git-hooks/pre-commit (every file staged for a commit) and the +# "File size" CI job (every blob any commit in a pull request introduces), so +# the limit and the message live in one place instead of two. +# +# Usage: dev/check-blob-size +# Exit 0 if the blob is within the limit (or isn't a blob at all - not +# this script's problem), 1 with a message on stderr if it's over. + +limit=$((256 * 1024)) # 256 KiB + +oid="$1" +path="$2" + +size=$(git cat-file -s "$oid") || exit 0 +if [ "$size" -gt "$limit" ]; then + printf 'oversized file: %s is %s bytes, over the %s byte limit (blob %s).\n' \ + "$path" "$size" "$limit" "$oid" >&2 + exit 1 +fi diff --git a/dev/git-hooks/pre-commit b/dev/git-hooks/pre-commit index 55cb6ea0..3420c515 100755 --- a/dev/git-hooks/pre-commit +++ b/dev/git-hooks/pre-commit @@ -5,30 +5,33 @@ # or copy at http://www.boost.org/LICENSE_1_0.txt) # Reject a commit that stages an oversized file. Bypass with `git commit -# --no-verify` when a large file really is project content. +# --no-verify` when a large file really is project content. The "File size" +# CI job runs the same check (via dev/check-blob-size) server-side, over +# every blob a pull request's commits introduce, so a large file can't reach +# develop just because a contributor skipped installing this hook. # # Not installed automatically - run dev/install-git-hooks once per clone (or # per worktree that doesn't share this one's .git/hooks; linked worktrees do). # -# Sizes come from the index (`git cat-file -s :path`), not the working tree, so -# this is right for `git add -p`, for `git commit ` (git hands the hook -# a temporary index), and on a repo with no commits yet. --diff-filter=ACMR -# skips deletions, which have no index entry to size. +# Sizes come from the index (`git cat-file -s :path`, inside +# dev/check-blob-size), not the working tree, so this is right for +# `git add -p`, for `git commit ` (git hands the hook a temporary +# index), and on a repo with no commits yet. --diff-filter=ACMR skips +# deletions, which have no index entry to size. # # bash, not sh: `read -d` is a bashism, and dash is /bin/sh here. The redirect # from a process substitution keeps the loop out of a subshell, so `status` -# survives it and every offender gets reported, not just the first. +# survives it and every offender gets reported, not just the first. Git runs +# hooks with the working tree's top level as the current directory, so the +# plain relative path below reaches the right worktree's own copy of the +# script even when .git/hooks/pre-commit is a symlink shared from another one +# (dev/install-git-hooks points it at the worktree it was run from). -limit=$((256 * 1024)) # 256 KiB status=0 while IFS= read -r -d '' f; do - size=$(git cat-file -s ":$f") || continue - if [ "$size" -gt "$limit" ]; then - printf 'pre-commit: %s is %s bytes, over the %s byte limit.\n' \ - "$f" "$size" "$limit" >&2 - status=1 - fi + oid=$(git rev-parse ":$f") || continue + dev/check-blob-size "$oid" "$f" || status=1 done < <(git diff --cached --name-only --diff-filter=ACMR -z) if [ "$status" -ne 0 ]; then