#!/usr/bin/env bats # ----------------------------------------------------------------------------- # Unit tests for hack/check-commit-trailers.sh. # # Each test builds a throwaway git repository, writes commits with the message # shape under test, and runs the checker over base..HEAD — the same range shape # the workflow computes from the merge base. Real commits rather than a canned # list, because merge commits and the trailer block are git's behaviour, not the # checker's. # # cozytest.sh's awk parser recognizes only @test blocks and a bare `}` on its # own line; there is no bats `run` or `$status`. Assertions are direct shell # tests, and an expected failure is written as `if CHECK ...; then ... exit 1`. # Each test runs in its own subshell, so the `cd` into the fixture does not leak. # ----------------------------------------------------------------------------- load test_helper REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)" CHECK="$REPO_ROOT/hack/check-commit-trailers.sh" # A repository with one seed commit on `base`, left as the current directory. # Signing and hooks are off so the fixture behaves the same on a maintainer's # machine as on a runner: core.hooksPath is inherited from the global config, # and a global commit-msg hook would otherwise rewrite the messages under test. make_repo() { dir="$(mktemp -d)" cd "$dir" git init -q . git config user.email ci@example.invalid git config user.name CI git config commit.gpgsign false git config tag.gpgsign false git config core.hooksPath /dev/null echo seed > file git add file git commit -q -m "seed" git branch -f base HEAD git tag start } # Message on stdin. A merge test moves `base`, so ranges are taken from the # `start` tag, which nothing moves. commit() { echo change >> file git add file git commit -q -F - } # Last line of a test body rather than a trap, which the runners forbid: both # set -e, so a failing test never reaches this and its repository survives for # inspection, which is what a failed test wants. cleanup() { cd / rm -rf "$dir" } # The three `git config` lines in make_repo are there because a contributor's # global configuration reaches a fresh repository, and a runner carries no such # configuration: a guard that goes missing stays green in CI and turns red only # on the machine of whoever signs their commits or tags, which is how tag.gpgsign # came to be missing in the first place. This test brings the hostile # configuration with it, so the guards are held by the suite rather than by # whichever machine happens to run it. gpg.program is a command that always # fails, so an attempt to sign is a deterministic error rather than a question # about which keys the host holds. @test "make_repo holds against a hostile global configuration" { home="$(mktemp -d)" mkdir -p "$home/hooks" cat > "$home/hooks/commit-msg" <<'HOOK' #!/bin/sh echo "rewritten by a global hook" > "$1" HOOK chmod +x "$home/hooks/commit-msg" cat > "$home/.gitconfig" <&2 exit 1 fi # core.hooksPath: the global commit-msg hook would have rewritten the # message, and commit.gpgsign would have failed the commit outright. if [ "$(git log -1 --format=%s base)" != seed ]; then echo "expected the global commit-msg hook to be inert" >&2 exit 1 fi cleanup rm -rf "$home" } @test "accepts the documented Assisted-by: LLM trailer" { make_repo commit <<'EOF' feat(ci): add a thing Signed-off-by: CI Assisted-by: LLM EOF "$CHECK" base..HEAD cleanup } @test "accepts a commit with no trailer at all" { make_repo commit <<'EOF' fix(ci): repair a thing A body with no trailer block, because no model took part. EOF "$CHECK" base..HEAD cleanup } @test "fails on an empty range instead of reporting OK" { # The pre-push recipe resolves origin/main..HEAD against the working # directory's HEAD, so an agent whose shell sits in a different checkout # lands on an empty range. A run that examined nothing must not read as a # clean one; grepping the message, not just the exit code, pins that the # verdict names the empty case rather than dropping the OK line by accident. make_repo if out="$("$CHECK" base..HEAD 2>&1)"; then echo "expected an empty range to fail" >&2 exit 1 fi printf '%s\n' "$out" | grep -q "empty" if printf '%s\n' "$out" | grep -q "Commit trailers OK"; then echo "an empty range still printed the OK verdict" >&2 exit 1 fi cleanup } @test "rejects a vendor-named attribution trailer and names the commit" { make_repo commit <<'EOF' feat(ci): add a thing Signed-off-by: CI Assisted-By: Sonnet EOF sha="$(git rev-parse --short HEAD)" if out="$("$CHECK" base..HEAD 2>&1)"; then echo "expected a vendor-named trailer to fail" >&2 exit 1 fi printf '%s\n' "$out" | grep -q "$sha" printf '%s\n' "$out" | grep -q "must be exactly LLM" printf '%s\n' "$out" | grep -q "Assisted-by: LLM" cleanup } @test "rejects an attribution value that is not LLM even without a vendor name" { make_repo commit <<'EOF' feat(ci): add a thing Assisted-by: AI EOF if "$CHECK" base..HEAD >/dev/null 2>&1; then echo "expected Assisted-by: AI to fail" >&2 exit 1 fi cleanup } @test "ignores the casing of the trailer key but not of the value" { make_repo commit <<'EOF' feat(ci): add a thing Assisted-By: LLM EOF "$CHECK" base..HEAD commit <<'EOF' feat(ci): add another thing Assisted-by: llm EOF if "$CHECK" base..HEAD >/dev/null 2>&1; then echo "expected a lowercase value to fail" >&2 exit 1 fi cleanup } @test "rejects two attribution trailers on one commit" { make_repo commit <<'EOF' feat(ci): add a thing Assisted-by: LLM Assisted-by: LLM EOF if out="$("$CHECK" base..HEAD 2>&1)"; then echo "expected a duplicated trailer to fail" >&2 exit 1 fi printf '%s\n' "$out" | grep -q "at most one is accepted" cleanup } @test "rejects a session trailer" { make_repo commit <<'EOF' feat(ci): add a thing Assisted-by: LLM Claude-Session: https://example.invalid/s/abc123 EOF if out="$("$CHECK" base..HEAD 2>&1)"; then echo "expected a session trailer to fail" >&2 exit 1 fi printf '%s\n' "$out" | grep -q "session trailer is not accepted" cleanup } @test "rejects a link to an assistant session anywhere in the message" { make_repo commit <<'EOF' feat(ci): add a thing Worked out in https://claude.ai/chat/00000000-0000-0000-0000-000000000000 EOF if out="$("$CHECK" base..HEAD 2>&1)"; then echo "expected a session link to fail" >&2 exit 1 fi printf '%s\n' "$out" | grep -q "links to an assistant session" cleanup } @test "reads an assistant host as prose when it is not a URL" { make_repo commit <<'EOF' docs(agents): say where the rule comes from The trailer rule applies to every assistant, chatgpt.com included. EOF "$CHECK" base..HEAD cleanup } @test "passes a merge commit that carries no trailer of its own" { make_repo git checkout -q -b side commit <<'EOF' feat(ci): add a thing on a side branch Assisted-by: LLM EOF git checkout -q base commit <<'EOF' fix(ci): move base along EOF git merge -q --no-ff --no-edit side n="$(git rev-list --count start..HEAD)" [ "$n" -ge 3 ] git rev-list --min-parents=2 start..HEAD | grep -q . "$CHECK" start..HEAD cleanup } @test "rejects a bad trailer carried by a merge commit itself" { make_repo git checkout -q -b side commit <<'EOF' feat(ci): add a thing on a side branch EOF git checkout -q base git merge -q --no-ff -m "Merge branch 'side' Assisted-By: Sonnet " side if "$CHECK" start..HEAD >/dev/null 2>&1; then echo "expected a merge commit with a bad trailer to fail" >&2 exit 1 fi cleanup } @test "reports every offending commit in the range, not just the first" { make_repo commit <<'EOF' feat(ci): first Assisted-By: Sonnet EOF first="$(git rev-parse --short HEAD)" commit <<'EOF' feat(ci): second Assisted-By: Opus EOF second="$(git rev-parse --short HEAD)" if out="$("$CHECK" base..HEAD 2>&1)"; then echo "expected two bad commits to fail" >&2 exit 1 fi printf '%s\n' "$out" | grep -q "$first" printf '%s\n' "$out" | grep -q "$second" cleanup } @test "requires a range argument" { # Grepping the usage line, not just the exit code: with the guard removed # an empty range makes git fail anyway, so the status alone proves nothing. make_repo if out="$("$CHECK" 2>&1)"; then echo "expected a missing range to fail" >&2 exit 1 fi printf '%s\n' "$out" | grep -q '^usage: ' cleanup } @test "fails on a range git cannot resolve instead of reporting success" { # A misspelled remote, or a clone that never fetched origin/main, is the # shape a developer meets when running this by hand. Walking zero commits # and printing OK would be the worst possible answer. make_repo if "$CHECK" no-such-ref..HEAD >/dev/null 2>&1; then echo "expected an unresolvable range to fail" >&2 exit 1 fi cleanup } @test "accepts the documented form in a message with CRLF line endings" { # Trailing whitespace is stripped from a message under git's default # cleanup, but --cleanup=verbatim keeps it, and so does a message written by # an editor on Windows. The carriage return must not turn LLM into LLM\r. make_repo echo change >> file git add file printf 'feat(ci): add a thing\r\n\r\nAssisted-by: LLM\r\n' \ | git commit -q --cleanup=verbatim -F - git log -1 --format=%B | grep -q "$(printf 'LLM\r')" "$CHECK" start..HEAD cleanup } @test "rejects a continuation line folded into the attribution trailer" { # git reads the two lines as the single value "LLM continuation", which is # not LLM. Reading them as two lines would accept it. make_repo commit <<'EOF' feat(ci): add a thing Assisted-by: LLM continuation EOF git log -1 --format=%B | git interpret-trailers --parse \ | grep -qx 'Assisted-by: LLM continuation' if "$CHECK" start..HEAD >/dev/null 2>&1; then echo "expected a folded continuation to fail" >&2 exit 1 fi cleanup } @test "rejects a generation byline naming the tool" { make_repo commit <<'EOF' feat(ci): add a thing 🤖 Generated with [Some Tool](https://example.invalid/tool) EOF if out="$("$CHECK" start..HEAD 2>&1)"; then echo "expected a generation byline to fail" >&2 exit 1 fi printf '%s\n' "$out" | grep -q "generation byline" cleanup } @test "leaves ordinary prose about generated files alone" { # Both shapes are real lines from this repository's history, and both keep # their small g. Failing either one would make the byline rule unusable. make_repo commit <<'EOF' feat(ci): add a thing Regenerated with cozyvalues-gen v1.5.0, which the CI pin has moved past. The README in packages/apps/redis was generated with cozyvalues-gen 1.5.0 while CI pins 1.6.0, and the older version orders the table differently. EOF "$CHECK" start..HEAD cleanup } @test "leaves the trailer keys it does not judge alone" { # These block at review, and the script says why it does not decide them: # telling a model from a person in a Co-authored-by line needs a list of # model names, and Generated-by has never appeared here. A later change # that starts failing these is a scope change, not a fix. make_repo commit <<'EOF' feat(ci): add a thing Co-authored-by: Sonnet Generated-by: Sonnet EOF "$CHECK" start..HEAD cleanup } @test "matches a session link whose scheme and host are uppercase" { make_repo commit <<'EOF' feat(ci): add a thing See HTTPS://CLAUDE.AI/chat/00000000-0000-0000-0000-000000000000 EOF if "$CHECK" start..HEAD >/dev/null 2>&1; then echo "expected an uppercase session link to fail" >&2 exit 1 fi cleanup } @test "leaves a matching host that is only userinfo alone" { # The host of this URL is somewhere.example, not chatgpt.com. make_repo commit <<'EOF' feat(ci): add a thing Reported at https://chatgpt.com@somewhere.example/path EOF "$CHECK" start..HEAD cleanup } @test "matches a session link carrying userinfo of its own" { # Here the host really is claude.ai; user@ in front of it changes nothing. make_repo commit <<'EOF' feat(ci): add a thing Worked out in https://user@claude.ai/chat/00000000-0000-0000-0000-000000000000 EOF if "$CHECK" start..HEAD >/dev/null 2>&1; then echo "expected a session link with userinfo to fail" >&2 exit 1 fi cleanup } @test "reads a space before the separator as git does" { make_repo commit <<'EOF' feat(ci): add a thing Assisted-by : Sonnet EOF git log -1 --format=%B | git interpret-trailers --parse \ | grep -q '^Assisted-by: Sonnet' if "$CHECK" start..HEAD >/dev/null 2>&1; then echo "expected a spaced key to fail" >&2 exit 1 fi cleanup } @test "leaves a documentation link on a vendor site alone" { # cursor.com and gemini.google.com serve manuals and pricing, not only # transcripts. A citation must not turn a pull request red. make_repo commit <<'EOF' docs(agents): cite the tooling Background at https://cursor.com/docs and https://gemini.google.com/about EOF "$CHECK" start..HEAD cleanup } @test "leaves a lookalike host alone" { # notchatgpt.com and chatgpt.com.evil belong to somebody else. Matching a # substring of the host would hand them a failing check they cannot fix. make_repo commit <<'EOF' feat(ci): add a thing Reported at https://notchatgpt.com/x and https://chatgpt.com.evil/y EOF "$CHECK" start..HEAD cleanup } @test "matches a session link served from a subdomain" { make_repo commit <<'EOF' feat(ci): add a thing See https://www.chatgpt.com/share/00000000-0000-0000-0000-000000000000 EOF if "$CHECK" start..HEAD >/dev/null 2>&1; then echo "expected a subdomain session link to fail" >&2 exit 1 fi cleanup }