Skip to content

Commit 68d800b

Browse files
mnriemCopilot
andcommitted
fix: respect LC_ALL and preserve Unicode stop words in Bash
Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent f23ccf4 commit 68d800b

3 files changed

Lines changed: 91 additions & 4 deletions

File tree

‎docs/reference/core.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ The Python helper also accepts `--short-name`; the PowerShell helper uses
8080
`-ShortName`. A supplied short name is cleaned by the same rules, so it must
8181
contain at least one letter or digit. For non-ASCII names, the Bash helper needs
8282
an installed UTF-8 locale and a Python 3 interpreter for Unicode classification.
83+
If `LC_ALL` is non-empty, Bash uses that locale rather than selecting another:
84+
Unicode names fail with an error if the selected locale is not usable for UTF-8
85+
names. With `LC_ALL` unset or empty, Bash selects an installed UTF-8 locale even
86+
when `LANG` or `LC_CTYPE` names a non-UTF-8 locale.
8387
ASCII capitals are lowercased; non-ASCII letter casing is preserved across the
8488
script variants.
8589

‎scripts/bash/create-new-feature.sh‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,11 @@ spec_prefix_exists() {
152152
# '+', leaving repeated separators uncollapsed on macOS.
153153
# * printf instead of echo, so a name of "-n"/"-e"/"-E" is text, not options.
154154
UNICODE_LOCALE=""
155-
for candidate in C.UTF-8 C.utf8 en_US.UTF-8 en_US.utf8 "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"; do
155+
locale_candidates=(C.UTF-8 C.utf8 en_US.UTF-8 en_US.utf8 "${LC_CTYPE:-${LANG:-}}")
156+
if [ -n "${LC_ALL:-}" ]; then
157+
locale_candidates=("$LC_ALL")
158+
fi
159+
for candidate in "${locale_candidates[@]}"; do
156160
if [ -n "$candidate" ] && [ "$(printf 'é。' | LC_ALL="$candidate" sed 's/[^[:alnum:]]/-/g' 2>/dev/null)" = 'é-' ]; then
157161
UNICODE_LOCALE="$candidate"
158162
break
@@ -162,7 +166,11 @@ done
162166
if [ -z "$UNICODE_LOCALE" ]; then
163167
UNICODE_LOCALE=C
164168
if printf '%s' "${SHORT_NAME:-$FEATURE_DESCRIPTION}" | LC_ALL=C grep -q '[^ -~]'; then
165-
echo "Error: A UTF-8 locale is required to create a Unicode feature name" >&2
169+
if [ -n "${LC_ALL:-}" ]; then
170+
echo "Error: A UTF-8 locale is required to create a Unicode feature name; LC_ALL=$LC_ALL is not usable" >&2
171+
else
172+
echo "Error: A UTF-8 locale is required to create a Unicode feature name" >&2
173+
fi
166174
exit 1
167175
fi
168176
fi
@@ -279,7 +287,7 @@ generate_branch_name() {
279287
[ -z "$word" ] && continue
280288

281289
# Retain non-ASCII words even when shorter than three characters.
282-
if ! echo "$word" | grep -qiE "$stop_words"; then
290+
if ! printf '%s\n' "$word" | LC_ALL=C grep -qE "$stop_words"; then
283291
if [ ${#word} -ge 3 ] || printf '%s' "$word" | LC_ALL=C grep -q '[^ -~]'; then
284292
meaningful_words+=("$word")
285293
# Keep short words that appear as an uppercase acronym in the original.

‎tests/test_create_new_feature_python_parity.py‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,32 @@ def test_bash_reports_missing_utf8_locale_for_unicode_only(
217217
assert not (repo / "specs").exists()
218218

219219

220+
@requires_bash
221+
@pytest.mark.parametrize("locale_name", ["C", "POSIX"])
222+
def test_bash_respects_explicit_non_utf8_lc_all(repo: Path, locale_name: str) -> None:
223+
env = clean_env()
224+
env["LC_ALL"] = locale_name
225+
env["LANG"] = "C.UTF-8"
226+
227+
ascii_result = run(
228+
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "Add user authentication"),
229+
repo,
230+
env,
231+
)
232+
assert ascii_result.returncode == 0, ascii_result.stderr
233+
assert json_stdout(ascii_result)["BRANCH_NAME"] == "001-user-authentication"
234+
235+
for args in (("添加用户",), ("--short-name", "用户", "Add users")):
236+
unicode_result = run(
237+
bash_cmd(repo, SCRIPT, "--json", "--dry-run", *args), repo, env
238+
)
239+
assert unicode_result.returncode == 1
240+
assert unicode_result.stdout == ""
241+
assert "A UTF-8 locale is required" in unicode_result.stderr
242+
assert "LC_ALL" in unicode_result.stderr
243+
assert not (repo / "specs").exists()
244+
245+
220246
@requires_bash
221247
def test_bash_requires_python_only_for_unicode_names(
222248
repo: Path, tmp_path: Path
@@ -381,6 +407,54 @@ def test_python_branch_name_generation_matches_bash(
381407
assert json_stdout(py) == json_stdout(bash)
382408

383409

410+
@requires_bash
411+
@pytest.mark.parametrize(
412+
("description", "expected"),
413+
[("ſet account", "001-ſet-account"), ("Set account", "001-account")],
414+
)
415+
def test_bash_stop_words_match_only_ascii_words(
416+
repo: Path, description: str, expected: str
417+
) -> None:
418+
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
419+
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
420+
421+
assert bash.returncode == py.returncode == 0
422+
assert json_stdout(bash) == json_stdout(py)
423+
assert json_stdout(bash)["BRANCH_NAME"] == expected
424+
425+
426+
@requires_bash
427+
def test_bash_stop_words_ignore_locale_case_folding(repo: Path, tmp_path: Path) -> None:
428+
real_grep = shutil.which("grep")
429+
assert real_grep is not None
430+
shim_dir = tmp_path / "bin"
431+
shim_dir.mkdir()
432+
grep_shim = shim_dir / "grep"
433+
grep_cmd = shlex.quote(_bash_posix_path(Path(real_grep)))
434+
grep_shim.write_text(
435+
"#!/bin/sh\n"
436+
'if [ "$1" = "-qiE" ]; then\n'
437+
" IFS= read -r word\n"
438+
' [ "$word" = "ſet" ] && exit 0\n'
439+
f' printf "%s\\n" "$word" | {grep_cmd} "$@"\n'
440+
" exit $?\n"
441+
"fi\n"
442+
f'exec {grep_cmd} "$@"\n',
443+
encoding="utf-8",
444+
newline="\n",
445+
)
446+
grep_shim.chmod(0o755)
447+
env = clean_env()
448+
env["PATH"] = f"{shim_dir}{os.pathsep}{env['PATH']}"
449+
450+
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "ſet account"), repo, env)
451+
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "ſet account"), repo, env)
452+
453+
assert bash.returncode == py.returncode == 0
454+
assert json_stdout(bash) == json_stdout(py)
455+
assert json_stdout(bash)["BRANCH_NAME"] == "001-ſet-account"
456+
457+
384458
@requires_bash
385459
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
386460
@pytest.mark.parametrize(
@@ -1489,7 +1563,8 @@ def test_no_ascii_word_description_matches_across_twins(
14891563
ps_repo = _setup_repo(tmp_path, "s")
14901564

14911565
env = clean_env()
1492-
env["LC_ALL"] = env["LANG"] = "C"
1566+
env.pop("LC_ALL", None)
1567+
env["LANG"] = "C"
14931568
bash = run(
14941569
bash_cmd(bash_repo, SCRIPT, "--json", "--dry-run", description), bash_repo, env
14951570
)

0 commit comments

Comments
 (0)