diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6448219..d98ace4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,56 @@ on: pull_request: jobs: + unicode-without-en-us: + runs-on: ubuntu-latest + container: ubuntu:24.04 + steps: + - name: Install test prerequisites + run: | + apt-get update + apt-get install -y ca-certificates git python3-jsonschema + - uses: actions/checkout@v4 + - name: Confirm the image has C.utf8 but no en_US UTF-8 locale + shell: bash + run: | + set -euo pipefail + found_c=0 + found_en_us=0 + while IFS= read -r installed; do + printf '%s\n' "$installed" + case "${installed,,}" in + c.utf8 | c.utf-8) found_c=1 ;; + en_us.utf8 | en_us.utf-8) found_en_us=1 ;; + esac + done < <(locale -a) + [[ "$found_c" == 1 ]] + [[ "$found_en_us" == 0 ]] + - name: Reproduce the old merged-stream contamination + shell: bash + run: | + set -euo pipefail + probe="$(mktemp -d)" + mkdir -p "$probe/home" "$probe/work" + git -C "$probe/work" init -q -b main + export HOME="$probe/home" + export GIT_LOCKS_STORE="$probe/store.git" + merged="$(cd "$probe/work" && LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 "$GITHUB_WORKSPACE/bin/git-locks" claim --job unicode-red --holder 'héloïse' 'café/naïve.md' 2>&1)" + [[ "$merged" == *setlocale* ]] + [[ "$merged" == *'"event":"claimed"'* ]] + if python3 -c 'import json, sys; [json.loads(line) for line in sys.stdin if line.strip()]' <<<"$merged"; then + echo 'expected the merged warning and JSON stream to fail JSON parsing' >&2 + exit 1 + fi + - name: Test Unicode output under the installed UTF-8 locale + shell: bash + run: | + git config --global user.email ci@example.invalid + git config --global user.name ci + bash test/unicode-locale.sh + no_locale_output="$(GIT_LOCKS_TEST_LOCALES=$'C\nPOSIX' bash test/unicode-locale.sh)" + printf '%s\n' "$no_locale_output" + [[ "$no_locale_output" == *'SKIP Unicode claim/list/check integration'* ]] + lint-and-test: runs-on: ubuntu-latest steps: @@ -25,7 +75,7 @@ jobs: make test release: - needs: lint-and-test + needs: [lint-and-test, unicode-without-en-us] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0a60c..e1d1bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project are recorded here. The format follows Keep a ## [Unreleased] +### Fixed + +- Unicode integration tests select an installed UTF-8 locale, keep JSON stdout separate from shell diagnostics, and report an explicit skip when no UTF-8 locale is available. + ## [0.7.0] - 2026-09-16 ### Added diff --git a/Makefile b/Makefile index fede374..2e607dc 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := /usr/bin/env bash # lib/*.sh are fragments of one script and only lint as the whole they build into (bin/git-locks). -SCRIPTS := bin/git-locks test/test.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh +SCRIPTS := bin/git-locks test/test.sh test/unicode-locale.sh test/unicode-locale-calibration.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh PREFIX ?= $(HOME)/.local .PHONY: build lint test test-docker install uninstall @@ -14,9 +14,11 @@ lint: test: bash test/test.sh + bash test/unicode-locale.sh + bash test/unicode-locale-calibration.sh test-docker: # the same suite inside the official bash image, for a wall between the tests and your machine - docker run --rm -v "$(CURDIR)":/src -w /src bash:5.2 bash -c 'apk add --no-cache git python3 py3-jsonschema >/dev/null && git config --global user.email t@example.invalid && git config --global user.name t && bash test/test.sh' + docker run --rm -v "$(CURDIR)":/src -w /src bash:5.2 bash -c 'apk add --no-cache git python3 py3-jsonschema >/dev/null && git config --global user.email t@example.invalid && git config --global user.name t && bash test/test.sh && bash test/unicode-locale.sh && bash test/unicode-locale-calibration.sh' install: mkdir -p $(PREFIX)/bin diff --git a/README.md b/README.md index 64cdd64..d1e3868 100644 --- a/README.md +++ b/README.md @@ -435,11 +435,11 @@ git locks list # git dispatches `git locks` to git-locks on PATH ```sh make build # assemble bin/git-locks from lib/*.sh and schema/git-locks.schema.json make lint # shellcheck with every optional check on, shfmt -make test # test/test.sh, pure bash, temporary repositories; needs python3 with jsonschema for the schema checks +make test # pure bash, temporary repositories; needs python3 with jsonschema for the schema checks git config --local core.hooksPath scripts/hooks # pre-commit lints, pre-push tests ``` -The source is `lib/`, one module per section in numeric order (`000-prelude.sh` through `990-main.sh`); `bin/git-locks` is the build product and is committed, because it is what `make install`, the release asset and a `curl` of the raw file all want: one file, no runtime assembly. Edit under `lib/`, run `make build`, commit both. The suite checks that the committed script is exactly what `lib/` builds, so a `lib/` change without a rebuild fails the pre-push hook and CI. The schema module is generated at build time from `schema/git-locks.schema.json`, so there is one copy of the schema in the repository. Lint runs over the built script rather than the fragments, which do not parse on their own. +The source is `lib/`, one module per section in numeric order (`000-prelude.sh` through `990-main.sh`); `bin/git-locks` is the build product and is committed, because it is what `make install`, the release asset and a `curl` of the raw file all want: one file, no runtime assembly. Edit under `lib/`, run `make build`, commit both. The suite checks that the committed script is exactly what `lib/` builds, so a `lib/` change without a rebuild fails the pre-push hook and CI. The schema module is generated at build time from `schema/git-locks.schema.json`, so there is one copy of the schema in the repository. Lint runs over the built script rather than the fragments, which do not parse on their own. The Unicode integration test selects an installed UTF-8 locale and checks JSON stdout separately from shell diagnostics; when no UTF-8 locale exists, it reports the skipped integration with the installation prerequisite. ## Limits, stated diff --git a/test/test.sh b/test/test.sh index 22657a5..7143957 100755 --- a/test/test.sh +++ b/test/test.sh @@ -1294,16 +1294,6 @@ check "and a newline at with" "$?" "2" git-locks check c2.md >/dev/null 2>&1 check "none of those refusals left a lock behind" "$?" "0" -out="$(LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 git-locks claim --job u --holder 'héloïse' 'café/naïve.md' 2>&1)" -check "a non-ASCII holder and path claim under a UTF-8 locale" "$?" "0" -out="$(LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 git-locks list 2>&1)" -check "and list under that locale exits 0" "$?" "0" -contains "with the holder intact" "${out}" '"holder":"héloïse"' -contains "and the path intact" "${out}" '"paths":["café/naïve.md"]' -valid "list lines with non-ASCII text" "${out}" -out="$(LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 git-locks check 'café/naïve.md' 2>&1)" -check "check sees it held" "$?" "1" - out="$(git-locks with --job z --holder h --sem o --ttl 0 -- true 2>&1)" check "with --sem refuses --ttl 0 before acquiring anything" "$?" "2" out="$(git-locks sem show o 2>&1)" diff --git a/test/unicode-locale-calibration.sh b/test/unicode-locale-calibration.sh new file mode 100755 index 0000000..7b12ddd --- /dev/null +++ b/test/unicode-locale-calibration.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Prove that skipping the live Unicode integration cannot hide a failure in the +# deterministic locale-selection checks that run before it. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PASS=0 +FAIL=0 + +check() { # label got want + if [[ "$2" == "$3" ]]; then + PASS=$((PASS + 1)) + printf ' ok %s\n' "$1" + else + FAIL=$((FAIL + 1)) + printf ' FAIL %s\n got: %q\n want: %q\n' "$1" "$2" "$3" + fi +} + +out="$(GIT_LOCKS_TEST_LOCALES=$'C\nPOSIX' GIT_LOCKS_TEST_CALIBRATE_FAILURE=1 bash "${HERE}/unicode-locale.sh" 2>&1)" +rc=$? +check "a skipped integration preserves an earlier fixture failure" "${rc}" "1" +case "${out}" in + *'1 failed, 1 skipped'*) got=yes ;; + *) got=no ;; +esac +check "the calibration reports both the failure and the skip" "${got}" "yes" + +printf '\n%d passed, %d failed\n' "${PASS}" "${FAIL}" +((FAIL == 0)) diff --git a/test/unicode-locale.sh b/test/unicode-locale.sh new file mode 100755 index 0000000..54337b6 --- /dev/null +++ b/test/unicode-locale.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Unicode integration coverage that adapts to the UTF-8 locales installed on +# the current system. JSON stdout and shell diagnostics are checked separately. +set -uo pipefail + +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_NAMESPACE + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PATH="${HERE}/../bin:${PATH}" +SCHEMA_FILE="${HERE}/../schema/git-locks.schema.json" +PASS=0 +FAIL=0 +SKIP=0 +FAILED=() + +check() { # label got want + if [[ "$2" == "$3" ]]; then + PASS=$((PASS + 1)) + printf ' ok %s\n' "$1" + else + FAIL=$((FAIL + 1)) + FAILED+=("$1") + printf ' FAIL %s\n got: %q\n want: %q\n' "$1" "$2" "$3" + fi +} + +contains() { # label haystack needle + if [[ "$2" == *"$3"* ]]; then + PASS=$((PASS + 1)) + printf ' ok %s\n' "$1" + else + FAIL=$((FAIL + 1)) + FAILED+=("$1") + printf ' FAIL %s\n text: %q\n lacks: %q\n' "$1" "$2" "$3" + fi +} + +valid() { # label text + local rc + python3 -c ' +import json, sys +import jsonschema +schema = json.load(open(sys.argv[1])) +for line in sys.argv[2].splitlines(): + if line.strip(): + jsonschema.validate(json.loads(line), schema) +' "${SCHEMA_FILE}" "$2" >/dev/null 2>&1 + rc=$? + check "$1 validates against schema/git-locks.schema.json" "${rc}" "0" +} + +finish() { + printf '\n%d passed, %d failed, %d skipped\n' "${PASS}" "${FAIL}" "${SKIP}" + if ((FAIL > 0)); then + printf 'failed:' + printf ' %s;' "${FAILED[@]}" + printf '\n' + return 1 + fi + return 0 +} + +select_utf8_locale() { # VAR [locale-a output]: prefer C, then en_US, then any installed UTF-8 locale + local output_name="$1" available line normalized en_us='' fallback='' + if (($# > 1)); then + available="$2" + else + available="$(locale -a 2>/dev/null)" || return 1 + fi + while IFS= read -r line; do + [[ -n "${line}" ]] || continue + normalized="${line,,}" + case "${normalized}" in + c.utf-8 | c.utf8) + printf -v "${output_name}" '%s' "${line}" + return 0 + ;; + en_us.utf-8 | en_us.utf8) + [[ -n "${en_us}" ]] || en_us="${line}" + ;; + *.utf-8 | *.utf8) + [[ -n "${fallback}" ]] || fallback="${line}" + ;; + *) ;; + esac + done <<<"${available}" + if [[ -n "${en_us}" ]]; then + printf -v "${output_name}" '%s' "${en_us}" + return 0 + fi + if [[ -n "${fallback}" ]]; then + printf -v "${output_name}" '%s' "${fallback}" + return 0 + fi + return 1 +} + +# Fixed inventories cover platform spellings, preference order, fallback and +# the no-UTF-8 boundary without depending on the machine running the suite. +selected='' +select_utf8_locale selected $'en_US.UTF-8\nC\nC.utf8\nPOSIX' +expected_c_utf8='C.utf8' +[[ "${GIT_LOCKS_TEST_CALIBRATE_FAILURE:-0}" == 1 ]] && expected_c_utf8='calibration-mismatch' +check "locale selection prefers C.utf8 when Linux provides it" "${selected}" "${expected_c_utf8}" +selected='' +select_utf8_locale selected $'C\nC.UTF-8\nPOSIX' +check "locale selection accepts the C.UTF-8 spelling" "${selected}" "C.UTF-8" +selected='' +select_utf8_locale selected $'C\nen_US.utf8\nPOSIX' +check "locale selection accepts the en_US.utf8 spelling" "${selected}" "en_US.utf8" +selected='' +select_utf8_locale selected $'C\nfr_FR.UTF-8\nPOSIX' +check "locale selection falls back to another installed UTF-8 locale" "${selected}" "fr_FR.UTF-8" +selected='' +if select_utf8_locale selected $'C\nPOSIX'; then rc=0; else rc=$?; fi +check "locale selection reports when no UTF-8 locale exists" "${rc}" "1" + +UTF8_LOCALE='' +locale_available=0 +if [[ -n "${GIT_LOCKS_TEST_LOCALES+x}" ]]; then + select_utf8_locale UTF8_LOCALE "${GIT_LOCKS_TEST_LOCALES}" && locale_available=1 +else + select_utf8_locale UTF8_LOCALE && locale_available=1 +fi +if ((locale_available == 0)); then + SKIP=$((SKIP + 1)) + printf ' SKIP Unicode claim/list/check integration: install a UTF-8 locale to run it\n' + finish + exit $? +fi + +printf ' info Unicode integration locale: %s\n' "${UTF8_LOCALE}" + +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/git-locks-unicode.XXXXXX")" +cleanup() { + if [[ -n "${TEST_ROOT:-}" && "${TEST_ROOT}" == "${TMPDIR:-/tmp}"/git-locks-unicode.* ]]; then + rm -rf -- "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +export HOME="${TEST_ROOT}/home" +export GIT_LOCKS_STORE="${TEST_ROOT}/store.git" +export GIT_LOCKS_NOW=1000000 +mkdir -p "${HOME}" "${TEST_ROOT}/work" +git -C "${TEST_ROOT}/work" init -q -b main +cd "${TEST_ROOT}/work" || exit 2 +STDOUT_FILE="${TEST_ROOT}/stdout" +STDERR_FILE="${TEST_ROOT}/stderr" + +LC_ALL="${UTF8_LOCALE}" LANG="${UTF8_LOCALE}" git-locks claim --job unicode --holder 'héloïse' 'café/naïve.md' >"${STDOUT_FILE}" 2>"${STDERR_FILE}" +rc=$? +out="$(<"${STDOUT_FILE}")" +err="$(<"${STDERR_FILE}")" +check "Unicode claim exits 0" "${rc}" "0" +check "Unicode claim writes no diagnostics" "${err}" "" +contains "Unicode claim keeps the holder" "${out}" '"holder":"héloïse"' +contains "Unicode claim keeps the path" "${out}" '"paths":["café/naïve.md"]' +valid "Unicode claim stdout" "${out}" + +LC_ALL="${UTF8_LOCALE}" LANG="${UTF8_LOCALE}" git-locks list >"${STDOUT_FILE}" 2>"${STDERR_FILE}" +rc=$? +out="$(<"${STDOUT_FILE}")" +err="$(<"${STDERR_FILE}")" +check "Unicode list exits 0" "${rc}" "0" +check "Unicode list writes no diagnostics" "${err}" "" +contains "Unicode list keeps the holder" "${out}" '"holder":"héloïse"' +contains "Unicode list keeps the path" "${out}" '"paths":["café/naïve.md"]' +valid "Unicode list stdout" "${out}" + +LC_ALL="${UTF8_LOCALE}" LANG="${UTF8_LOCALE}" git-locks check 'café/naïve.md' >"${STDOUT_FILE}" 2>"${STDERR_FILE}" +rc=$? +out="$(<"${STDOUT_FILE}")" +err="$(<"${STDERR_FILE}")" +check "Unicode check sees the path held" "${rc}" "1" +check "Unicode check writes no diagnostics" "${err}" "" +contains "Unicode check keeps the holder" "${out}" '"holder":"héloïse"' +contains "Unicode check keeps the path" "${out}" '"path":"café/naïve.md"' +valid "Unicode check stdout" "${out}" + +finish