Skip to content

Commit c7e0cac

Browse files
authored
fix: PS 5.1 compat — replace non-ASCII chars in shipped PowerShell scripts (#2709)
* Initial plan * fix: replace non-ASCII chars in PS1 files, add encoding regression tests, fix ANSI stripping in tests, update docs --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 0f9beab commit c7e0cac

9 files changed

Lines changed: 83 additions & 23 deletions

File tree

extensions/git/commands/speckit.git.initialize.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Replace the script to add project-specific Git initialization steps:
3535
## Output
3636

3737
On success:
38-
- ` Git repository initialized`
38+
- `[OK] Git repository initialized`
3939

4040
## Graceful Degradation
4141

extensions/git/scripts/powershell/auto-commit.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ if (Test-Path $configFile) {
115115
}
116116
}
117117
} else {
118-
# No config file auto-commit disabled by default
118+
# No config file -- auto-commit disabled by default
119119
exit 0
120120
}
121121

extensions/git/scripts/powershell/git-common.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env pwsh
22
# Git-specific common functions for the git extension.
3-
# Extracted from scripts/powershell/common.ps1 contains only git-specific
3+
# Extracted from scripts/powershell/common.ps1 -- contains only git-specific
44
# branch validation and detection logic.
55

66
function Test-HasGit {

extensions/git/scripts/powershell/initialize-repo.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env pwsh
22
# Git extension: initialize-repo.ps1
33
# Initialize a Git repository with an initial commit.
4-
# Customizable replace this script to add .gitignore templates,
4+
# Customizable -- replace this script to add .gitignore templates,
55
# default branch config, git-flow, LFS, signing, etc.
66
$ErrorActionPreference = 'Stop'
77

@@ -66,4 +66,4 @@ try {
6666
exit 1
6767
}
6868

69-
Write-Host " Git repository initialized"
69+
Write-Host "[OK] Git repository initialized"

scripts/powershell/common.ps1

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -336,21 +336,21 @@ function Get-FeaturePathsEnv {
336336
function Test-FileExists {
337337
param([string]$Path, [string]$Description)
338338
if (Test-Path -Path $Path -PathType Leaf) {
339-
Write-Output " $Description"
339+
Write-Output " [OK] $Description"
340340
return $true
341341
} else {
342-
Write-Output " $Description"
342+
Write-Output " [FAIL] $Description"
343343
return $false
344344
}
345345
}
346346

347347
function Test-DirHasFiles {
348348
param([string]$Path, [string]$Description)
349349
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
350-
Write-Output " $Description"
350+
Write-Output " [OK] $Description"
351351
return $true
352352
} else {
353-
Write-Output " $Description"
353+
Write-Output " [FAIL] $Description"
354354
return $false
355355
}
356356
}
@@ -591,7 +591,7 @@ except Exception:
591591

592592
if ($layerPaths.Count -eq 0) { return $null }
593593

594-
# If the top (highest-priority) layer is replace, it wins entirely
594+
# If the top (highest-priority) layer is replace, it wins entirely --
595595
# lower layers are irrelevant regardless of their strategies.
596596
if ($layerStrategies[0] -eq 'replace') {
597597
return (Get-Content $layerPaths[0] -Raw)

scripts/powershell/create-new-feature.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ if (-not $DryRun) {
312312
if ($AllowExistingBranch) {
313313
# If we're already on the branch, continue without another checkout.
314314
if ($currentBranch -eq $branchName) {
315-
# Already on the target branch nothing to do
315+
# Already on the target branch -- nothing to do
316316
} else {
317317
# Otherwise switch to the existing branch instead of failing.
318318
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String

tests/integrations/test_cli.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -335,10 +335,11 @@ def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
335335
_install_shared_infra(project, "sh", force=False)
336336

337337
captured = capsys.readouterr()
338-
assert "already exist and were not updated" in captured.out
339-
assert "specify init --here --force" in captured.out
338+
plain = strip_ansi(captured.out)
339+
assert "already exist and were not updated" in plain
340+
assert "specify init --here --force" in plain
340341
# Rich may wrap long lines; normalize whitespace for the second command
341-
normalized = " ".join(captured.out.split())
342+
normalized = " ".join(plain.split())
342343
assert "specify integration upgrade --force" in normalized
343344

344345
def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys):

tests/integrations/test_integration_subcommand.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typer.testing import CliRunner
88

99
from specify_cli import app
10+
from tests.conftest import strip_ansi
1011

1112

1213
runner = CliRunner()
@@ -49,7 +50,8 @@ def _write_invalid_manifest(project, key):
4950

5051

5152
def _integration_list_row_cells(output: str, key: str) -> list[str]:
52-
row = next(line for line in output.splitlines() if line.startswith(f"│ {key}"))
53+
plain = strip_ansi(output)
54+
row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}"))
5355
return [cell.strip() for cell in row.split("│")[1:-1]]
5456

5557

@@ -160,8 +162,9 @@ def test_install_already_installed(self, tmp_path):
160162
finally:
161163
os.chdir(old_cwd)
162164
assert result.exit_code == 0
163-
assert "already installed" in result.output
164-
normalized = " ".join(result.output.split())
165+
plain = strip_ansi(result.output)
166+
assert "already installed" in plain
167+
normalized = " ".join(plain.split())
165168
assert "specify integration upgrade copilot" in normalized
166169
assert "already the default integration" in normalized
167170
assert "No files were changed" in normalized
@@ -197,9 +200,10 @@ def test_install_different_when_one_exists(self, tmp_path):
197200
finally:
198201
os.chdir(old_cwd)
199202
assert result.exit_code != 0
200-
assert "Installed integrations: copilot" in result.output
201-
assert "Default integration: copilot" in result.output
202-
normalized = " ".join(result.output.split())
203+
plain = strip_ansi(result.output)
204+
assert "Installed integrations: copilot" in plain
205+
assert "Default integration: copilot" in plain
206+
normalized = " ".join(plain.split())
203207
assert "To replace the default integration" in normalized
204208
assert "specify integration switch claude" in normalized
205209
assert "To install 'claude' alongside" in normalized
@@ -309,9 +313,10 @@ def test_install_multi_unsafe_requires_force(self, tmp_path):
309313
finally:
310314
os.chdir(old_cwd)
311315
assert result.exit_code != 0
312-
assert "Installed integrations: copilot" in result.output
313-
assert "multi-install safe" in result.output
314-
normalized = " ".join(result.output.split())
316+
plain = strip_ansi(result.output)
317+
assert "Installed integrations: copilot" in plain
318+
assert "multi-install safe" in plain
319+
normalized = " ".join(plain.split())
315320
assert "To replace the default integration" in normalized
316321
assert "specify integration switch claude" in normalized
317322
assert "To install 'claude' alongside" in normalized

tests/test_ps1_encoding.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Regression tests for PowerShell 5.1 compatibility (GitHub issue #2680).
2+
3+
PowerShell 5.1 (built-in on Windows) defaults to the system's legacy encoding
4+
when reading .ps1 files. Non-ASCII characters in UTF-8-encoded scripts cause
5+
parse errors because multi-byte sequences are misinterpreted as individual bytes.
6+
7+
These tests ensure that all shipped .ps1 files remain ASCII-only so they work
8+
on both PowerShell 5.1 and 7+.
9+
"""
10+
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
REPO_ROOT = Path(__file__).resolve().parent.parent
16+
17+
# All directories that contain shipped PowerShell scripts.
18+
_PS1_DIRS = [
19+
REPO_ROOT / "scripts" / "powershell",
20+
REPO_ROOT / "extensions" / "git" / "scripts" / "powershell",
21+
]
22+
23+
24+
def _collect_ps1_files():
25+
"""Yield all .ps1 files under the known script directories."""
26+
for d in _PS1_DIRS:
27+
if d.is_dir():
28+
yield from sorted(d.rglob("*.ps1"))
29+
30+
31+
_PS1_FILES = list(_collect_ps1_files())
32+
33+
34+
@pytest.mark.parametrize("ps1_file", _PS1_FILES, ids=lambda p: str(p.relative_to(REPO_ROOT)))
35+
def test_ps1_file_is_ascii_only(ps1_file: Path):
36+
"""Every .ps1 file must contain only ASCII characters (PS 5.1 compat)."""
37+
content = ps1_file.read_bytes()
38+
non_ascii = [
39+
(i + 1, byte)
40+
for i, byte in enumerate(content)
41+
if byte > 127
42+
]
43+
assert not non_ascii, (
44+
f"{ps1_file.relative_to(REPO_ROOT)} contains non-ASCII bytes "
45+
f"(PowerShell 5.1 incompatible): "
46+
f"first at byte offset {non_ascii[0][0]} (0x{non_ascii[0][1]:02x})"
47+
)
48+
49+
50+
def test_ps1_files_discovered():
51+
"""Sanity check: at least the known script files are found."""
52+
names = {p.name for p in _PS1_FILES}
53+
assert "common.ps1" in names
54+
assert "initialize-repo.ps1" in names

0 commit comments

Comments
 (0)