From f3fbaa265e858ae9694b846d5b577d55665f49a4 Mon Sep 17 00:00:00 2001 From: SEPURI-SAI-KRISHNA Date: Sun, 23 Aug 2026 14:39:34 +0530 Subject: [PATCH 1/2] fix(scripts): make bash branch-name sanitizing match the Python and PowerShell twins --- .../scripts/bash/create-new-feature-branch.sh | 15 ++- scripts/bash/create-new-feature.sh | 17 +++- .../git/test_git_extension_python_parity.py | 61 ++++++++++-- tests/parity_helpers.py | 31 ++++++ .../test_create_new_feature_python_parity.py | 94 +++++++++++++++++++ 5 files changed, 209 insertions(+), 9 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 856cb0bec4..5303bb97c9 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -206,9 +206,18 @@ check_existing_branches() { } # Function to clean and format a branch name +# +# Three details keep this byte-identical to the Python and PowerShell twins: +# * LC_ALL=C -- in a UTF-8 locale glibc resolves the a-z *range* through +# collation, so [^a-z0-9] keeps accented lowercase letters that +# re.sub(r"[^a-z0-9]", ...) and .NET's -replace both strip. +# * `--*` instead of the GNU-only `\+`, which POSIX/BSD sed reads as a literal +# '+', leaving repeated separators uncollapsed on macOS. +# * printf instead of echo, so a name of "-n"/"-e"/"-E" is text, not options. clean_branch_name() { local name="$1" - echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' + local -x LC_ALL=C + printf '%s\n' "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//' } # --------------------------------------------------------------------------- @@ -444,6 +453,10 @@ generate_branch_name() { local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + # LC_ALL=C for the same collation reason documented on clean_branch_name, + # and so the `grep -qw` acronym probe below uses ASCII word boundaries like + # the Python twin's (? dict[str, str]: return env +def collation_range_locale() -> str | None: + """A locale whose ``[a-z]`` bracket range is collation-ordered, or ``None``. + + glibc resolves a bracket-expression *range* through the locale's collation + table, so under ``en_US.UTF-8`` ``[^a-z0-9]`` leaves accented lowercase + letters alone while ``C.UTF-8`` and the POSIX locale strip them. Probe + ``sed`` directly rather than trusting a locale name: the environments where + the divergence cannot be reproduced (no such locale installed, a non-glibc + libc, Git-for-Windows) are exactly the ones where the probe comes back + clean, so the caller can skip. + """ + for name in ("en_US.UTF-8", "en_US.utf8"): + env = clean_env() + env["LC_ALL"] = name + env["LANG"] = name + try: + probe = subprocess.run( + ["sed", "s/[^a-z0-9]/-/g"], + input="é\n", + capture_output=True, + text=True, + check=False, + env=env, + ) + except OSError: # pragma: no cover - sed missing entirely + return None + if probe.returncode == 0 and "é" in probe.stdout: + return name + return None + + def run( cmd: list[str], repo: Path, env: dict[str, str] | None = None ) -> subprocess.CompletedProcess[str]: diff --git a/tests/test_create_new_feature_python_parity.py b/tests/test_create_new_feature_python_parity.py index 41122b1f5f..b3dc4adbe1 100644 --- a/tests/test_create_new_feature_python_parity.py +++ b/tests/test_create_new_feature_python_parity.py @@ -14,6 +14,8 @@ HAS_POWERSHELL, bash_cmd, break_wrap_layer, + clean_env, + collation_range_locale, install_composition_stack, install_scripts, json_stdout, @@ -1065,3 +1067,95 @@ def test_all_variants_corrected_prefix_skips_timestamp_collision(repo: Path) -> assert json_stdout(py)["FEATURE_NUM"] == "20260320" for result in (bash, ps, py): assert "using 20260320 instead" in result.stderr + + +@requires_bash +@pytest.mark.parametrize( + "description", + [ + "Añadir autenticación de usuario", + "Prüfung für Benutzer anlegen", + "Ajouter la réservation hôtelière", + ], + ids=["spanish", "german", "french"], +) +def test_bash_branch_name_ignores_locale_collation( + repo: Path, description: str +) -> None: + """Branch naming must not depend on the caller's locale. + + ``clean_branch_name``/``generate_branch_name`` sanitize with + ``sed 's/[^a-z0-9]/-/g'``. Run under a collation-ordered locale that class + keeps accented lowercase letters, so bash produced + ``001-ajouter-réservation-hôtelière`` where the Python and PowerShell twins + produce ``001-ajouter-servation-teli``: the same description yielded a + different ``specs/`` directory on two machines that differ only in ``LANG``. + """ + locale_name = collation_range_locale() + if locale_name is None: + pytest.skip("no locale with collation-ordered [a-z] ranges available") + + env = clean_env() + env["LC_ALL"] = locale_name + env["LANG"] = locale_name + + bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo, env) + py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo, env) + + assert py.returncode == bash.returncode == 0 + assert json_stdout(py) == json_stdout(bash) + branch = json_stdout(bash)["BRANCH_NAME"] + assert isinstance(branch, str) and branch.isascii(), branch + + +@requires_bash +@pytest.mark.parametrize( + ("short_name", "expected"), + [ + ("My Fancy!! Name", "001-my-fancy-name"), + ("auth -- v2", "001-auth-v2"), + ], + ids=["punctuation_run", "separator_run"], +) +def test_bash_collapses_repeated_separators( + repo: Path, short_name: str, expected: str +) -> None: + """Runs of non-alphanumeric characters collapse to a single hyphen. + + The bash twin squeezed them with ``sed 's/-\\+/-/g'``. ``\\+`` is a GNU + extension, not POSIX BRE: BSD ``sed`` (macOS) reads it as a literal ``+``, + so nothing collapsed and the branch became ``001-my-fancy---name``. + """ + bash = run( + bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--short-name", short_name, "x"), + repo, + ) + py = run( + py_cmd(repo, SCRIPT, "--json", "--dry-run", "--short-name", short_name, "x"), + repo, + ) + + assert bash.returncode == py.returncode == 0 + assert json_stdout(bash) == json_stdout(py) + assert json_stdout(bash)["BRANCH_NAME"] == expected + + +@requires_bash +@pytest.mark.parametrize("short_name", ["-n", "-e", "-E"], ids=["n", "e", "E"]) +def test_python_dash_prefixed_short_name_matches_bash( + repo: Path, short_name: str +) -> None: + """A short name that looks like an ``echo`` option is still text. + + ``clean_branch_name`` piped the raw value through ``echo "$name"``, so bash + consumed ``-n``/``-e``/``-E`` as options and emitted nothing, yielding the + suffix-less ``001-`` where Python yields ``001-n``. + """ + args = ("--json", "--dry-run", "--short-name", short_name, "x") + bash = run(bash_cmd(repo, SCRIPT, *args), repo) + py = run(py_cmd(repo, SCRIPT, *args), repo) + + assert py.returncode == bash.returncode == 0 + assert json_stdout(py) == json_stdout(bash) + expected = f"001-{short_name.lstrip('-').lower()}" + assert json_stdout(bash)["BRANCH_NAME"] == expected From 20827ec969ecf535d5ae298fdfa9e6df7c0454f9 Mon Sep 17 00:00:00 2001 From: SEPURI-SAI-KRISHNA Date: Sun, 23 Aug 2026 17:06:14 +0530 Subject: [PATCH 2/2] fix(scripts): use ASCII acronym boundaries in the Python and PowerShell twins --- .../powershell/create-new-feature-branch.ps1 | 6 ++-- .../python/create_new_feature_branch.py | 11 +++++-- scripts/powershell/create-new-feature.ps1 | 6 ++-- .../git/test_git_extension_python_parity.py | 29 +++++++++++++++++++ .../test_create_new_feature_python_parity.py | 12 +++++++- 5 files changed, 57 insertions(+), 7 deletions(-) diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 2d6f2bcfec..4d06a113a4 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -420,11 +420,13 @@ function Get-BranchName { if ($stopWords -contains $word) { continue } if ($word.Length -ge 3) { $meaningfulWords += $word - } elseif ($Description -cmatch "\b$($word.ToUpper())\b") { + } elseif ($Description -cmatch "(? str: if len(word) >= 3: meaningful_words.append(word) # Keep short words only when they appear uppercased in the original - # description (acronyms like "API" or "DB"). - elif re.search(rf"\b{re.escape(word.upper())}\b", description): + # description (acronyms like "API" or "DB"). The boundaries are spelled + # out as ASCII rather than using \b: \b is Unicode-aware on str, so + # "\u00e9DB\u00e9 cache" would hide the acronym behind a non-ASCII word + # character, while the bash twin's `grep -qw` runs under LC_ALL=C and + # sees a boundary there. + elif re.search( + rf"(?= 3 OR appear as uppercase in original (likely acronyms) if ($word.Length -ge 3) { $meaningfulWords += $word - } elseif ($Description -cmatch "\b$($word.ToUpper())\b") { + } elseif ($Description -cmatch "(?