Skip to content
Open
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
52 changes: 51 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 0 additions & 10 deletions test/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
30 changes: 30 additions & 0 deletions test/unicode-locale-calibration.sh
Original file line number Diff line number Diff line change
@@ -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))
181 changes: 181 additions & 0 deletions test/unicode-locale.sh
Original file line number Diff line number Diff line change
@@ -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
Loading