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
4 changes: 2 additions & 2 deletions .claude/hooks/check-affected-pre-push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ segment_push_dir() {
w="$1"
shift
case "$w" in
--delete | --dry-run | -[nd]* | -[!-]*[nd]*) return 1 ;;
--delete | --dry-run | -[dn]* | -[!-]*[dn]*) return 1 ;;
--tags) tags=1 ;;
-o | --push-option | --repo | --receive-pack | --exec) shift ;;
-*) ;;
Expand Down Expand Up @@ -287,7 +287,7 @@ run_checks() (
extract_tools "$top" "$mb" "$tmp/tools" || exit 2
fi
[[ -x "$script" ]] || exit 2
(cd "$data" && bash "$script" 2>&1) || rc=$?
(cd "$data" && CHECK_DATA_ROOT="$data" bash "$script" 2>&1) || rc=$?
exit "$rc"
)

Expand Down
37 changes: 25 additions & 12 deletions scripts/check-affected.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@
# is covered, and scripts/check-affected-gates.test.sh fails when a gate in
# the workflow's hygiene jobs is in neither column.
#
# Code runs from the tree holding this script; the files checked are those of
# the current directory's repository. The pre-push hook runs a merge-base copy
# of this script against a branch, so the two differ: then every check script
# runs from the copy with CHECK_DATA_ROOT naming the branch tree, markdownlint
# Code runs from the tree holding this script, and the files checked are that
# tree's too, whatever the working directory, unless CHECK_DATA_ROOT names
# another tree. The pre-push hook runs a merge-base copy of this script with
# CHECK_DATA_ROOT naming the branch tree, so the two differ: then every check
# script runs from the copy with CHECK_DATA_ROOT passed on, markdownlint
# reads only the copy's configs (a branch config can load a custom rule, which
# is code), and checks that run the branch's own code skip: --tests, changed-skills, and the
# validate-plugins steps that report `SKIP` themselves.
Expand All @@ -66,7 +67,8 @@
# A missing tool, a failed fetch, or a check that exits 2 (could not run) is a
# SKIP with its reason, never a failure. Exit 0 every check passed or skipped;
# 1 a check failed, with each failure and its reproduce command on stderr; 2
# usage or a git state the changed set cannot be read from. That is the
# usage or a git state the changed set cannot be read from, an empty changed
# set while HEAD is not the base among them. That is the
# check-script family contract stated in README.md.
set -uo pipefail

Expand Down Expand Up @@ -96,11 +98,11 @@ while (($#)); do
done

tools="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" || exit 2
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "check-affected: not inside a git work tree" >&2
top="$(git -C "${CHECK_DATA_ROOT:-$tools}" rev-parse --show-toplevel 2>/dev/null)" || {
echo "check-affected: ${CHECK_DATA_ROOT:-$tools} is not inside a git work tree" >&2
exit 2
fi
cd "$(git rev-parse --show-toplevel)" || exit 2
}
cd "$top" || exit 2

if ((uncommitted)); then
# The scratch repository has no remote, so the base is fetched and resolved
Expand Down Expand Up @@ -144,8 +146,13 @@ if ((uncommitted)); then
fi
fi
tree="$(git write-tree)" || exit 2
commit="$(git -c user.name=check-affected -c user.email=check-affected@localhost \
commit-tree "$tree" -p HEAD -m 'check-affected: uncommitted work')" || exit 2
# No edits: the snapshot is HEAD itself, so a clean tree is HEAD at its base.
if [[ "$tree" == "$(git rev-parse "HEAD^{tree}")" ]]; then
commit="$(git rev-parse HEAD)" || exit 2
else
commit="$(git -c user.name=check-affected -c user.email=check-affected@localhost \
commit-tree "$tree" -p HEAD -m 'check-affected: uncommitted work')" || exit 2
fi
unset GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
git -c init.defaultBranch=snapshot init --quiet --template= "$snap/tree" || exit 2
printf '%s\n' "$snap/objects" "$objects" >"$snap/tree/.git/objects/info/alternates" || exit 2
Expand All @@ -155,7 +162,7 @@ if ((uncommitted)); then
args=(--base "$base" --no-fetch)
((run_tests)) && args+=(--tests)
rc=0
(cd "$snap/tree" && bash "$tools/scripts/check-affected.sh" "${args[@]}") || rc=$?
CHECK_DATA_ROOT="$snap/tree" bash "$tools/scripts/check-affected.sh" "${args[@]}" || rc=$?
exit "$rc"
fi
base="${base:-origin/main}"
Expand Down Expand Up @@ -219,6 +226,12 @@ if ! all_raw="$(git diff --name-only "$merge_base" HEAD)" ||
fi
mapfile -t changed <<<"$changed_raw"
[[ -n "${changed[0]-}" ]] || changed=()
# Nothing changed is a pass only when HEAD is the base; otherwise the run read
# the wrong tree or range, and passing would check nothing.
if [[ -z "$all_raw" && "$(git rev-list --count "$base...HEAD")" != 0 ]]; then
echo "check-affected: no changed files in $top since $merge_base, yet HEAD is not $base, so nothing would be checked. Run the check-affected.sh inside the tree you mean to check, or name that tree in CHECK_DATA_ROOT." >&2
exit 2
fi

if ((${#changed[@]})) && ! git diff --quiet HEAD -- "${changed[@]}"; then
echo "check-affected: SKIP every check (uncommitted edits to changed files; the checks read the working tree, a push sends HEAD, and CI checks the pushed commit). Commit or stash them, then rerun." >&2
Expand Down
61 changes: 54 additions & 7 deletions scripts/check-affected.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,45 @@ check "every hygiene gate the runner does not run is named CI only" \
check "a gate the runner runs is not named CI only" \
'[[ $ERR != *"CI only: markdown "* && $ERR != *"CI only: spoke-plugin-root "* && $ERR != *"CI only: typos "* ]]'

# The tree checked is the one holding the script, whatever the working
# directory: run by path from another checkout whose HEAD is at its base (a main
# checkout beside a worktree), it still lists the branch's one change, notes.txt.
other="$tmp/other"
git_test_config "$tmp" clone -q --branch base "$repo" "$other"
git_test_config "$other" update-ref refs/remotes/origin/main "$(git_test_config "$repo" rev-parse HEAD)"
# run_from <dir> <args...>: runs the fixture repo's script from <dir>; sets RC, OUT, ERR.
run_from() {
local dir="$1"
shift
RC=0
OUT="$(cd "$dir" && bash "$repo/scripts/check-affected.sh" "$@" 2>"$tmp/err")" || RC=$?
ERR="$(cat "$tmp/err")"
}
run_from "$other" --base base --tests
check "run from another checkout, it lists the script's own branch's change" \
'[[ $RC == 0 && $OUT == *"over 1 changed files"* && $ERR != *"SKIP tests"* ]]'
git_test_config "$repo" update-ref refs/remotes/origin/main "$(git_test_config "$repo" rev-parse base)"
run_from "$other" --base origin/main --no-fetch
check "an explicit --base run from another checkout still lists the change" \
'[[ $RC == 0 && $OUT == *"over 1 changed files"* ]]'
git_test_config "$repo" update-ref -d refs/remotes/origin/main

# An empty changed set while HEAD is not the base means the wrong tree or range
# was read, never a pass: HEAD behind the base, and commits that change nothing.
RC=0
ERR="$(cd "$other" && bash scripts/check-affected.sh --base origin/main --no-fetch 2>&1)" || RC=$?
check "HEAD behind its base with no changed files exits non-zero with a message" \
'[[ $RC != 0 && $ERR == *"no changed files"* && $ERR != *"passed"* ]]'
git_test_config "$other" commit -q --allow-empty -m empty
RC=0
ERR="$(cd "$other" && bash scripts/check-affected.sh --base origin/base --no-fetch 2>&1)" || RC=$?
check "commits beyond the base that change no file exit non-zero with a message" \
'[[ $RC != 0 && $ERR == *"no changed files"* && $ERR != *"passed"* ]]'
git_test_config "$other" reset -q --hard origin/base
RC=0
OUT="$(cd "$other" && bash scripts/check-affected.sh --base base --no-fetch 2>/dev/null)" || RC=$?
check "HEAD at its base passes with nothing to check" '[[ $RC == 0 && $OUT == *"over 0 changed files"* ]]'

run STUB_TYPOS=2 -- --base base
check "a typos exit of 2 is a finding, not a check that could not run" \
'[[ $RC == 1 && $ERR == *"FAIL typos"* && $ERR != *"SKIP typos"* ]]'
Expand Down Expand Up @@ -168,9 +207,10 @@ run PATH="$tmp/bin:$PATH" --
check "a failed fetch skips the stale-base check only" \
'[[ $RC == 0 && $ERR == *"SKIP stale-base-overlap (git fetch of origin/main failed)"* && $OUT$ERR == *"validate-plugins (no plugin changed)"* ]]'

# Run from another tree (the pre-push hook's merge-base copy), every check
# script comes from that tree and is told which tree to read; the checked
# tree's own scripts and its test code never run.
# A copy outside the checked tree (the pre-push hook's merge-base copy) checks
# the tree CHECK_DATA_ROOT names: every check script comes from the copy and is
# told which tree to read; the checked tree's own scripts and its test code
# never run.
tools="$tmp/tools"
mkdir -p "$tools/scripts"
cp "$SELF_DIR/check-affected.sh" "$tools/scripts/"
Expand All @@ -183,7 +223,7 @@ printf 'import os, sys\nsys.stderr.write("copy of sync-plugin-options-docs read
cp "$SELF_DIR/check-affected-gates.txt" "$tools/scripts/"
chmod +x "$tools"/scripts/*.sh
RC=0
ERR="$(cd "$repo" && bash "$tools/scripts/check-affected.sh" --base base --tests 2>&1)" || RC=$?
ERR="$(cd "$tmp" && CHECK_DATA_ROOT="$repo" bash "$tools/scripts/check-affected.sh" --base base --tests 2>&1)" || RC=$?
check "a copy run elsewhere runs its own check scripts against the checked tree" \
'[[ $RC == 1 && $ERR == *"copy of check-shell-portability read $repo"* ]]'
check "a copy run elsewhere points the spoke, options-docs and changelog-parity checks at the checked tree" \
Expand All @@ -196,7 +236,7 @@ printf 'x\n' >"$repo/plugins/demo/skills/s/notes.txt"
git_test_config "$repo" add -A
git_test_config "$repo" commit -q -m skill
RC=0
ERR="$(cd "$repo" && bash "$tools/scripts/check-affected.sh" --base base 2>&1)" || RC=$?
ERR="$(cd "$tmp" && CHECK_DATA_ROOT="$repo" bash "$tools/scripts/check-affected.sh" --base base 2>&1)" || RC=$?
check "a copy run elsewhere skips changed-skills, which runs the checked tree's skill-quality code" \
'[[ $ERR == *"SKIP changed-skills (runs the checked tree"* && $ERR != *"copy of check-changed-skills"* ]]'
git_test_config "$repo" reset -q --hard HEAD~1
Expand All @@ -210,13 +250,13 @@ if command -v markdownlint-cli2 >/dev/null 2>&1; then
git_test_config "$repo" add -A
git_test_config "$repo" commit -q -m docs
RC=0
ERR="$(cd "$repo" && bash "$tools/scripts/check-affected.sh" --base base 2>&1)" || RC=$?
ERR="$(cd "$tmp" && CHECK_DATA_ROOT="$repo" bash "$tools/scripts/check-affected.sh" --base base 2>&1)" || RC=$?
check "a copy run elsewhere never loads the checked tree's markdownlint config" \
'[[ ! -e $tmp/md-payload && $ERR != *"SKIP markdown"* ]]'
printf '# Title\nText.\n' >"$repo/docs/a.md"
git_test_config "$repo" commit -q -am bad-md
RC=0
ERR="$(cd "$repo" && bash "$tools/scripts/check-affected.sh" --base base 2>&1)" || RC=$?
ERR="$(cd "$tmp" && CHECK_DATA_ROOT="$repo" bash "$tools/scripts/check-affected.sh" --base base 2>&1)" || RC=$?
check "a copy run elsewhere still lints the checked tree's markdown" \
'[[ $RC == 1 && $ERR == *"FAIL markdown"* && $ERR == *"MD022"* ]]'
else
Expand All @@ -237,6 +277,11 @@ printf '#!/usr/bin/env bash\necho hi\n' >"$repo/new-tool"
run -- --uncommitted
check "--uncommitted checks an untracked #! file's mode on disk" \
'[[ $RC == 1 && $ERR == *"FAIL exec-bit"* && $ERR == *"new-tool starts with #!"* ]]'
# The issue lane's wrapper sets CHECK_DATA_ROOT to the checkout; the snapshot,
# not the checkout's committed state, is still what gets checked.
run CHECK_DATA_ROOT="$repo" -- --uncommitted
check "--uncommitted under the lane's CHECK_DATA_ROOT still checks the work tree" \
'[[ $RC == 1 && $ERR == *"new-tool starts with #!"* ]]'
run CHECK_AFFECTED_SCRIPT_EXEC_BIT=1 -- --uncommitted
check "--uncommitted with CHECK_AFFECTED_SCRIPT_EXEC_BIT judges a new #! file as executable, as apply-patch commits it" \
'[[ $RC == 0 && $OUT == *"over 1 changed files"* && ! -x $repo/new-tool ]]'
Expand Down Expand Up @@ -289,6 +334,8 @@ else
ok "SKIP --uncommitted shellcheck case (no shellcheck with --rcfile here)"
fi
check "--uncommitted leaves HEAD, the index and the worktree list as they were" '[[ $(state) == "$before" ]]'
run -- --uncommitted
check "--uncommitted with no edits passes with nothing to check" '[[ $RC == 0 && $OUT == *"over 0 changed files"* ]]'

run -- --bogus
check "an unknown flag is a usage error" '[[ $RC == 2 ]]'
Expand Down
Loading