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/.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/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 new file mode 100755 index 00000000..3420c515 --- /dev/null +++ b/dev/git-hooks/pre-commit @@ -0,0 +1,41 @@ +#!/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. 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`, 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. 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). + +status=0 + +while IFS= read -r -d '' f; do + 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 + 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